Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
polyslug-development
pushery/polyslug-for-laravel
claude-code
routing
locale
sluggable
Sitemap
redirect
seo
slug
laravel
canonical
eloquent
multilingual
hreflang
sqids

Build and work with Polyslug — polymorphic, multilingual routable slugs for Eloquent. Covers the #[Polyslug] attribute and every option, leak-safe identity encoders, per-locale slugs and hreflang, self-healing canonical redirects, multi-tenant resolution gating, nested and slug-only URLs, sitemaps, /go short links, and testing. Use when adding slugs to a model, routing sluggable models, or configuring any Polyslug behavior.

Install
php artisan boost:add-skill pushery/polyslug-for-laravel

Save this content to: .claude/skills/polyslug-development/SKILL.md

# Polyslug Development

## When to use this skill

Use this skill when making an Eloquent model routable with a pretty URL, when routing or
resolving sluggable models, when adding multilingual/hreflang URLs, or when configuring any
Polyslug behavior (encoders, scoping, nesting, slug-only mode, sitemaps, short links).

## Core model

A route key is two parts joined by an underscore: `{slug}_{encodedId}`. The **slug** is
human-readable, per-locale, and free to change. The **encoded id** is an opaque token
(produced by an `IdentityEncoder` from the primary key) that actually resolves the model.
Route-model binding decodes the **id**, so a stale/wrong slug still resolves — then the
`polyslug.canonical` middleware 301s it to the current canonical URL. Every slug a model has
ever had is kept in `polyslug_slugs` (current row flagged `is_current`, the rest as history),
so no published URL ever dies.

## Making a model sluggable

Add the attribute, the trait, and the interface. The `polyslug_slugs` migration is registered
automatically (`php artisan migrate`). Scaffold with `php artisan make:polyslug Page`; a
namespace in the name becomes a subdirectory, so `Blog/Page` lands in `app/Models/Blog`.

```php
use Polyslug\Attributes\Polyslug;
use Polyslug\Concerns\HasPolyslug;
use Polyslug\Contracts\Sluggable;

#[Polyslug(source: 'title')]
class Page extends Model implements Sluggable
{
    use HasPolyslug;
}
```

## `#[Polyslug]` attribute options

| Option | Default | Meaning |
| --- | --- | --- |
| `source` | — | Column(s) the slug is built from (`string` or `array`; arrays join with a space). Required unless `slugless`. |
| `separator` | `'-'` | Word separator inside the slug: `-`, `.` or `~`. `_` (the slug/token delimiter) and `/` are refused. |
| `transliterate` | `Simple` | `TransliterationProfile::Simple` (ü→u) or `Din` (ü→ue). |
| `maxLength` | `null` | Trim the **slug** to at most N characters (never mid-separator); a uniqueness suffix such as `-2` comes on top. Without it the slug is still cut to its column (255 by default). It does NOT shorten the `_{token}` after it — see `polyslug.random_token.length`. Refused with `slugless`. |
| `unique` | `true` | Append `-2`, `-3`, … on a collision. |
| `scope` | `null` | Column(s) that scope uniqueness (e.g. `tenant_id`, `parent_id`). |
| `reserved` | `[]` | Slugs that may never be assigned. Override `polyslugReservedWords(array $inherited): array` on the model to filter, replace or clear everything it inherits (own list + `reserved.global` + route-derived words); returning `[]` opts out entirely. Needed for a model behind a prefix like `/@owner/repo`, where a slug cannot shadow a route and every reservation is a false positive that silently becomes `api-2`. |
| `immutable` | `false` | Freeze the slug — never regenerate on source change. |
| `encoder` | `null` | Per-model `IdentityEncoder` class overriding the global one. |
| `onDelete` | `'keep'` | On soft-delete: `keep` reserves the slug; `release` frees it. Hard/force delete always cascades slug rows. A delete by query fires no event: call `Model::polyslugForget($keys)` with its keys, which deletes the slug rows and keeps the short links and tokens taken without naming the records. |
| `emptyFallback` | `'id-only'` | A source with no sluggable characters (CJK/emoji-only): `id-only` stores an empty slug (URL is `_{id}`) so the save never fails, and on an `idLess` model makes the encoded id the slug; `throw` raises `CouldNotGenerateSlug`. |
| `encoderOptions` | `[]` | Per-model encoder settings — a dedicated token space. Sqids: `alphabet`, `min_length`. `RandomTokenEncoder` / `SequentialTokenEncoder`: `length`, `alphabet`; the one left out keeps the application's setting. A key the effective encoder does not understand is ignored. |
| `unicode` | `'ascii'` | `native` keeps Unicode letters/numbers (non-Latin markets); slugs are lower-cased at generation so the case-insensitive unique index is consistent on PostgreSQL and SQLite. |
| `preserveCase` | `false` | Store the slug as written (`Octo-Org`) instead of folding it. Display only: uniqueness and resolution already compare case-insensitively, so nothing about collisions or lookups changes. Goes with `idLess` (that is where the case reaches the URL). Refused with `unicode: 'native'` — `lower()` disagrees across engines on non-ASCII, so an unfolded native slug would collide on one and not another. |
| `idLess` | `false` | Drop the `_{encodedId}` suffix — the URL is the slug alone; resolution is by slug (see Slug-only). |
| `reclaim` | `false` | Only with `idLess`. Releases a retired slug so another record may claim the name. Refused without `idLess`. |
| `slugless` | `false` | Drop the slug — the URL is the encoder token alone (`/lists/k3f9dlq7`). The mirror image of `idLess`; setting both is refused, as are `source`, `maxLength` and `reserved`, none of which do anything without a slug. Renaming the record cannot change its URL, and URLs published before the switch keep resolving and 301 to the short form. |
| `reclaimActive` | `false` | Only with `reclaim`. Also takes a name another record still holds actively: the holder's row is retired and the newcomer gets the name. The displaced record is then left with NO current slug until its own source is synced — listen for `SlugReclaimed`. Refused without `reclaim`. |

For dynamic (per-tenant/runtime) config, implement `Polyslug\Contracts\ConfiguresPolyslug` and
return a `PolyslugConfig` from `polyslug()` — it overrides the attribute.

## `config/polyslug.php`

`encoder`, `random_token.{length,alphabet}` and `sequential_token.{length,alphabet}` (the
stored-token encoders — `length` is a floor, a width whose space fills up widens by a
character rather than failing to issue a URL), `short_links.{scheme,length,alphabet}` (the
`/go` token, its own space; `scheme` is `random` or `sequential`, a null length takes the
scheme's default of 10 random / 1 counted), `sqids.{alphabet,min_length}`, `legacy_decoders`
(previous encoders to try on a decode miss — encoder migration), `write.max_attempts`, `locale.{source,route_param,missing,fallback_locale}`,
`reserved.{global,from_routes}`, `redirect.status` (the self-heal status, 301 by default),
`gone.{status,redirect_status}`, `retired.status`, `analytics.enabled`,
`backfill.{connection,queue,tries,timeout}` (where `polyslug:backfill --queue` puts its jobs;
all null by default, `--on-queue=`/`--on-connection=` override per run — a backfill left on the
default queue blocks every password reset behind it),
`open_graph.locale_map` (bare locales get NO og:locale — the format is language_TERRITORY,
and the territory is not the package's to invent; map `en => en_US` to get one),
`sitemap.{types,max_urls,max_bytes}` (the last two are the protocol's per-file ceilings,
50,000 URLs and 50 MB — past either, the command splits and writes an index), `types`
(polymorphic registry).

Five keys also read the environment, so a host can tighten them without publishing the file:
`POLYSLUG_SQIDS_MIN_LENGTH`, `POLYSLUG_WRITE_MAX_ATTEMPTS`, `POLYSLUG_BACKFILL_TIMEOUT` and
`POLYSLUG_BACKFILL_TRIES` (a value that is not a number, or one below the key's floor of 0, 1, 1
and 1, keeps the default) and `POLYSLUG_REQUIRE_SCOPE` (compared against `true`, so write `true`;
`1` does not enable it).

## Leak-safe identity encoders

`IdentityEncoder::encode(int|string): string` / `decode(string): int|string|null` (null → 404,
never a fuzzy match). Built in: `RandomTokenEncoder` (**the default** — unguessable random
token in `polyslug_tokens`, leak-free for integer keys), `SequentialTokenEncoder` (same
store, filled by counting: the shortest token not yet taken — `0`, `1`, … `z`, then `00` — so
the shortest URL there is, and completely predictable, which is the trade), `SqidsEncoder`
(obfuscation, not security: reversible, and it leaks the primary key, creation order and
growth rate; it needs the `bcmath` or `gmp` extension),
`UuidEncoder`, `UlidEncoder` (leaks creation time), `RawIdEncoder` (raw PK — internal only).
Non-canonical tokens (wrong length, leading zeros, re-encoded alias) resolve to a clean 404, so
each record has exactly one canonical URL. Migrate encoders without breaking links by listing
the previous encoder in `polyslug.legacy_decoders`.

A custom encoder that hits a store per key should also implement `BulkIdentityEncoder`
(`encodeMany(list<int|string> $ids): array<string, string>` — a token per key, keyed by the
**string** form of the id, so a caller can look one up with `(string) $model->getKey()`
regardless of whether the key was an int) — a **second** interface
extending `IdentityEncoder`, so an encoder you already wrote keeps satisfying its contract
untouched. It is what makes `polyslugPreload()` do anything: without it the preload skips your
encoder silently. Its result must equal encoding one key at a time — same tokens, same
collision handling — so it optimizes round trips and never the guarantees.

## Routing & self-healing

```php
Route::polyslug('/pages/{page}', [PageController::class, 'show']); // wires SubstituteBindings + polyslug.canonical in order
```

On a safe (GET/HEAD) request whose slug is stale, `polyslug.canonical` issues a redirect to the
current canonical URL. For `/{locale}/...` routes, set `polyslug.locale.source = 'route'` so the
middleware compares the route's `{locale}` segment (not the ambient app locale) and avoids
wrong-language 301 loops. `polyslugRouteKeyForLocale($locale)` builds a key for an explicit
locale (safe in queues/CLI).

**The application answers first.** The middleware works out what it would say, then runs the
route action and replaces only a **successful** response. If the action refuses — `abort(403)`,
`abort(404)`, or a returned non-2xx — that refusal reaches the client with no `Location` header.
This matters because the header of a canonical redirect carries the resolved row's slug, i.e.
usually its title, and a model on the open default `polyslugResolveQuery()` resolves any slug
to any row. Ordering `->middleware('can:...')` after the macro does NOT come earlier — Laravel's
priority sort leaves it behind `polyslug.canonical`. The cost: a request that ends in a redirect
runs the action first and discards its response, so a view counter counts it.

**Nested routes under `scopeBindings()`** (`/owners/{owner}/pages/{page}`) need
`use Polyslug\Concerns\ResolvesSluggableChildren;` on the parent model. Laravel binds the child
through the parent's relation by comparing the raw route value with the key column, which a
`slug_token` never matches (404). The trait resolves the value like the child's own binding and
narrows the relation, so another owner's page still 404s. It cannot sit on the child: `HasUuids`
and `HasUlids` define the child-side method, and the trait methods would collide. A route declared
`->withTrashed()` binds soft-deleted records, scoped or not, through the same decoding.

**A parameter that names a field** (`{page:uuid}`) binds by that column, through the
resolution gate, and `route()` builds its URL from the same column. The canonical middleware
never redirects such a parameter to a slug. The slug-and-token key does not resolve there.

## Rendering lists: eager-load `slugs`

```php
$pages = Page::query()->with('slugs')->paginate();   // links now cost no query per row
```

`currentSlug()`, `polyslugRouteKey()`, `slugLocales()` and everything on top of them
(`polyslugUrls()`, `hreflangLinks()`, `hreflangTags()`, `sitemapAlternateTags()`,
`Head::polyslug()`) read the loaded collection. Writes never do — they always re-read the
current row — and `slugHistory()` keeps querying, because history lives in the non-current
rows a narrowed eager load would omit.

Add `Page::polyslugPreload($pages)` after the query to remove the remaining per-row token
read of the default store-backed encoder. It is a no-op on Sqids/UUID/ULID/raw-key, so write
it unconditionally — with both in place the page issues no query per row.

**Resolving many identifiers** (an API that receives a list of public ids):
`Post::polyslugResolveMany($values)` returns `value => Post`, decoding the tokens in one query per
thousand and loading the records through `polyslugResolveQuery()` in one query per thousand keys. Each value may be `slug_TOKEN` or
the bare token; unknown, foreign-type and gated values are absent. Slug-only models resolve
one value at a time. The bulk encoder contract is `BulkIdentityDecoder` (`decodeManyWithin()`).

## Multi-tenant / draft isolation (required contract)

Override `polyslugResolveQuery()` to constrain which rows a slug may resolve to. Enforced across
bound routes, the polymorphic resolver, slug-only, and short links. A row outside the scope
resolves to a 404 indistinguishable from a nonexistent one (no existence oracle).

```php
public function polyslugResolveQuery(Builder $query): Builder
{
    return $query->where('tenant_id', tenant()->id)->where('published', true);
}
```

`polyslugIsRoutable(?string $locale = null): bool` keeps unpublished models/locales out of
hreflang sets and sitemaps.

## Multilingual & hreflang

One slug per locale; the reciprocal hreflang set is built from the same resolver as the
canonical URL, so they cannot drift.

```php
$page->setSlug('de', 'Hallo Welt');
$page->hreflangTags(fn (string $locale, string $key) => route('pages.show', [$locale, $key]));
```

Or in Blade: `@polyslugHreflang($page, $resolver)`.

A locale code is stored in 16 characters: language, script and region fit (`sr-Latn-RS`), a code
with a Unicode extension (`de-DE-u-co-phonebk`) does not, and is refused with an
`InvalidArgumentException` before any slug or short link is written.

**If one slug is served under several addresses, say so — nothing else can infer it.** The
hreflang set and the sitemap are both built from `slugLocales()`, which answers "which locales
hold slug text". Where a project pins each slug to one locale on purpose and still routes every
record under a locale prefix (`/u/lena` and `/de/u/lena`), that list has one entry forever, and
the second address is announced nowhere. Nothing fails — it is never discoverable, and
hreflang cannot rescue it because a crawler reads that only after fetching the page.

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

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

Opt-in, like `BulkIdentityEncoder`: without the interface nothing changes. The locale with no
slug of its own reuses the default locale's, which is what lets one slug serve both addresses.

## `laravel/head` (optional) {#laravel-head}

If `laravel/head` is installed, let it own the `<head>` instead of rendering tags yourself.
`Head::polyslug($model)` writes only what Polyslug is the authority on — canonical URL (from
the bound `PolyslugUrlResolver`, not the request), the reciprocal hreflang set, `og:locale`
(only in language_TERRITORY form — see `open_graph.locale_map`), plus one
`og:locale:alternate` per other locale, and a `robots` directive when
`polyslugIsRoutable()` is false. Title, description, cards and JSON-LD stay yours.

**A site-wide robots hint goes through `Head::defaults()`, never after `Head::polyslug()`.**
`laravel/head` replaces a string tag, so `Head::polyslug($gated)->robots('max-image-preview:large')`
erases the gate's `none` and publishes a page the app is hiding — nothing red, nothing logged.
A default applies as the base and loses to Polyslug, which is the outcome you want.

That directive defaults to `none`, which per spec means `noindex, nofollow` — a stronger claim
than the gate makes, since the gate is about indexability and says nothing about the links on
the page. Override `polyslugRobotsDirective(): string|array` on the model to answer
`['noindex', 'follow']` instead, which is usually what a draft or a gated preview wants
(`[RobotsRule::NoIndex, RobotsRule::Follow]` from `laravel/head` works the same). The
answer must still contain `noindex` or `none`; anything permissive, and anything empty, throws
`MisconfiguredPolyslug` rather than quietly un-gating the page. Say nothing and nothing
changes.

`laravel/head` needs **Laravel 13.17+** — its own floor, not Polyslug's, which is 13.0. On an
application pinned below that, Composer refuses the install; everything else in Polyslug works
unchanged.

```php
Head::polyslug($article)->title($article->title)->description($article->excerpt);
Head::polyslug($article, $request->route('locale')); // on a /{locale}/... route
```

Two rules. **Pick one hreflang path** — `Head::polyslug()` OR `@polyslugHreflang`, never both,
or the page carries two identical alternate sets. And **`Head::canonical()` alone is not
enough**: it falls back to the request URL, so on a route without `polyslug.canonical` a stale
slug renders and that tag names the outdated URL as canonical.

Requires a bound `PolyslugUrlResolver` for the URL tags; without one it writes only the robots
directive rather than guessing a URL shape.

`Route::polyslug()` works at both chain positions — bare, and after `middleware()`, `prefix()`
or `name()`.

## Nested (hierarchical) slugs

Override `polyslugParent()` to compose ancestor slugs into the path (`/electronics/phones/iphone_TOKEN`);
scope uniqueness on the parent key. Paths are computed from ancestors' current slugs, so a
rename/reparent self-heals via the canonical redirect (no cascade). Route with a catch-all:
`Route::polyslug('/{category}', ...)->where('category', '.*')`.

## Slug-only URLs (`idLess`)

`#[Polyslug(idLess: true)]` drops the id suffix. Resolution is by slug: current resolves
directly, a superseded slug 301s to the current URL, and retired slugs stay reserved so an old
URL can never point at a different model. The slug must be unique per `(type, locale, scope)`.

**Retired slugs stay reserved** — a later record asking for a freed name gets a suffix. Set
`reclaim: true` (idLess only) to release it instead: the newcomer takes the name and the URL
serves the new owner. Use it only when an external source already reassigns the name (a
mirror, an upstream registry); on app-owned slugs it turns a rename into a way to take over
somebody else's published URL.

**`reclaim` frees only a retired name.** A name another record still holds actively keeps
blocking, which is the state a mirror lands in when its upstream events arrive out of order:
upstream renames A to `y` and gives `x` to B, B's delivery arrives first, and B is named
`x-2` forever while the source says `x`. Add `reclaimActive: true` to take the name from the
live holder — its row is retired inside the same transaction, so the name is never owned by
nobody, and its old URL still resolves. The displaced record then has **no** current slug for
that locale until its own source is synced; the package cannot know what it should be called
instead. Listen for `Polyslug\Events\SlugReclaimed` (claimant, locale, slug,
previousOwnerType, previousOwnerId) and re-sync from there. It dispatches after commit, like
`SlugChanged`: inside your own transaction it waits for the commit, and a rollback drops it.

**On a scoped model the lookup needs the scope handed to it.** Uniqueness is per scope, so two
records may hold the same slug (`/@alice/toolkit`, `/@bob/toolkit`); a slug-only read has only
the string. Override `polyslugResolutionScope(): ?array` to return `['owner_id' => …]` — the
lookup is then filtered by the key the write path stored. Set
`polyslug.resolution.require_scope` to refuse a scoped lookup that names no scope instead of
returning whichever row sorts first.

## Sitemaps & short links

- **Bind `Polyslug\Contracts\PolyslugUrlResolver` first** — one method, model+locale → absolute URL,
  the one class the app writes itself because the package cannot know its routes. It feeds sitemaps,
  short links AND the `laravel/head` tags. Skipping it is the most common setup mistake, and two of
  the three fail **silently**: every `/go` link returns 404 (identical to an unknown token, so the
  route is not an existence oracle) and the head tags are not written. Only
  `polyslug:sitemap` names the contract. `polyslug:doctor` reports the binding.

```php
  // AppServiceProvider::register()
  $this->app->bind(PolyslugUrlResolver::class, fn () => new class implements PolyslugUrlResolver {
      public function url(Sluggable $model, string $locale): string
      {
          return route('pages.show', ['locale' => $locale, 'page' => $model->polyslugRouteKeyForLocale($locale)]);
      }
  });
  ```

  Use `polyslugRouteKeyForLocale($locale)`, not `getRouteKey()` — the latter answers for the current
  locale and would emit the same URL for every language.

**A model with no public address:** add an optional `canAddress(Sluggable $model): bool` to the
  resolver and return false for it. `url()` returns a string, so without that channel the only way
  to refuse is to throw — which `polyslug:sitemap` survives (the record is skipped, the type named
  at the end) but reports as an accident. A declared refusal is silent. The method is not on the
  interface, so an existing resolver needs no change.

- `php artisan polyslug:sitemap --path=public/sitemap.xml` — streams all `polyslug.sitemap.types` with hreflang alternates, honoring `polyslugIsRoutable()`.
  A type the resolver cannot address costs that type, never the document: one throw used to end the whole run, and a red scheduled run leaves the stale file in place.
  A database error is not a skip: it fails the run (non-zero exit). Files are written under temporary names and renamed into place only when the whole set is complete, so a failed run keeps the previous sitemap.
  One `<url>` per address, so a record served under three locales is three entries, each carrying the same alternate set.
  Past 50,000 URLs or 50 MB it writes `sitemap-1.xml`, `sitemap-2.xml`, … beside `--path` and a `<sitemapindex>` at `--path`;
  the index needs absolute URLs, so it takes `app.url` or `--base-url=` and fails rather than writing relative ones.
  `<lastmod>` is emitted only for a model that implements `polyslugLastModified(): ?DateTimeInterface` (on the trait, not the contract);
  it returns null by default because a timestamp that moves on every write gets the field disregarded site-wide. `<priority>`/`<changefreq>` are never emitted — engines ignore them.
- `$model->shortLink()` + route `ShortLinkController` at `/go/{token}` — a stable token that 301s to the current canonical URL (survives renames).

## Other operations

- Gone/supersede: `polyslugSupersededBy()` 301s to a successor; `polyslugIsGone()` returns a configurable 410.
- One old address must stop leading to a record (a rename forced by a trademark complaint): `$model->retireSlug('old-slug')` makes that former slug answer `retired.status` (410) instead of redirecting; other former slugs keep redirecting. Only a former slug can be retired (the current one throws), and it needs `polyslug.canonical` like self-healing.
  The successor is re-resolved through `polyslugResolveQuery()` before its route key is rendered, so a
  successor the requester may not see produces no redirect — you do NOT need to filter inside
  `polyslugSupersededBy()`. Use `polyslugResolveSelf()` for the same check on any model you obtained
  outside a resolution path and are about to disclose.
- **Seeding vs claiming on a `reclaimActive` model.** `setSlug()`/`polyslugSync()` **take** a name
  another record still holds — right for a webhook, where the source already handed it over.
  `seedSlug()`/`polyslugSeed()` **yield** — right for a backfill, where two existing records wanting
  one name is a conflict in the data, not a handover. `polyslug:backfill` seeds. Identical on a
  model that is not `reclaimActive`, so seeding is safe to use unconditionally.
- Backfill: `php artisan polyslug:backfill "App\\Models\\Page" [--locale=de] [--queue] [--chunk=N]`.
  The model class is a required argument, not an option — the command backfills one
  sluggable model at a time.
- Orphans: `php artisan polyslug:forget-orphans [--dry-run] [--type=…]` forgets the slugs, short
  links and tokens of every key whose record is gone (a foreign-key cascade, a delete by query that
  never called `polyslugForget()`), asked without global scopes, so a trashed record keeps its rows.
  Schedule it daily where foreign keys cascade.
- Analytics: `polyslug.analytics.enabled` fires a `SlugRedirected` event on each self-heal.
- Diagnostics: `php artisan polyslug:doctor` checks the encoder and token configuration, the
  model replacements in `polyslug.models`, the four status settings, the uniqueness indexes and
  the URL resolver, and reports every type registered in
  `polyslug.types` that still resolves through the open default `polyslugResolveQuery()`, the
  types on which any slug resolves to any row.

## Testing

Use the `InteractsWithPolyslug` trait in a test case:

```php
$this->assertHasCurrentSlug($page, 'hello-world');
$this->assertSlugResolves(Page::class, $page->getRouteKey(), $page->id);
$this->assertSlugRedirects('/pages/old-slug_TOKEN', '/pages/new-slug_TOKEN');
$this->assertSlugNotResolvable(Page::class, 'bad_token');
```

## Gotchas

- Add `polyslug.canonical` (or the `Route::polyslug` macro) or renames won't self-heal.
- Without `polyslugResolveQuery()`, a shared slug can resolve across tenants — always scope it in multi-tenant apps.
- **The gate is not the scope.** `polyslugResolveQuery()` filters by what the environment says is visible (session, tenant). A scope living in the URL (`/@owner/repo`) never reaches it, so on a slug-only model it separates nothing — use `polyslugResolutionScope()` for that.
- The slug never identifies the model; binding uses the encoded id. A wrong slug still resolves, then redirects.
- `idLess` requires globally-unique slugs per scope; retired slugs stay reserved by design.
- **`maxLength` never shortens the token.** A long URL is usually the token, not the slug: set `polyslug.random_token.length` (default 16), or `encoderOptions: ['length' => 8]` for one model. Both are floors, not fixed widths.
- **`polyslug_tokens` is keyed by `(morph type, id)` since 0.11.0** — `Page#1` and `Wishlist#1` hold different tokens, and a token addressed to another model type resolves to `null`. Upgrading runs a migration that backfills each token's owner, so published URLs survive; skipping it would mint a new token for every record. Scope `polyslugResolveQuery()` on anything that is not public regardless — it answers *may this viewer see this record*, which token separation does not.
- **A counted token is numbered when its URL is first built**, not when the record is saved. Ordinary traffic numbers records roughly in creation order; a bulk import that never renders a link numbers nothing. Use `polyslugPreload()` over the records in the order you want.
- Run `polyslug:doctor` after changing a token length or alphabet: an unbuildable one otherwise first refuses while a URL is being rendered, in production.
- Native Unicode mode assumes NFC-normalized input.

Polyslug Development

When to use this skill

Use this skill when making an Eloquent model routable with a pretty URL, when routing or resolving sluggable models, when adding multilingual/hreflang URLs, or when configuring any Polyslug behavior (encoders, scoping, nesting, slug-only mode, sitemaps, short links).

Core model

A route key is two parts joined by an underscore: {slug}_{encodedId}. The slug is human-readable, per-locale, and free to change. The encoded id is an opaque token (produced by an IdentityEncoder from the primary key) that actually resolves the model. Route-model binding decodes the id, so a stale/wrong slug still resolves — then the polyslug.canonical middleware 301s it to the current canonical URL. Every slug a model has ever had is kept in polyslug_slugs (current row flagged is_current, the rest as history), so no published URL ever dies.

Making a model sluggable

Add the attribute, the trait, and the interface. The polyslug_slugs migration is registered automatically (php artisan migrate). Scaffold with php artisan make:polyslug Page; a namespace in the name becomes a subdirectory, so Blog/Page lands in app/Models/Blog.

use Polyslug\Attributes\Polyslug;
use Polyslug\Concerns\HasPolyslug;
use Polyslug\Contracts\Sluggable;

#[Polyslug(source: 'title')]
class Page extends Model implements Sluggable
{
    use HasPolyslug;
}

#[Polyslug] attribute options

Option Default Meaning
source — Column(s) the slug is built from (string or array; arrays join with a space). Required unless slugless.
separator '-' Word separator inside the slug: -, . or ~. _ (the slug/token delimiter) and / are refused.
transliterate Simple TransliterationProfile::Simple (ü→u) or Din (ü→ue).
maxLength null Trim the slug to at most N characters (never mid-separator); a uniqueness suffix such as -2 comes on top. Without it the slug is still cut to its column (255 by default). It does NOT shorten the _{token} after it — see polyslug.random_token.length. Refused with slugless.
unique true Append -2, -3, … on a collision.
scope null Column(s) that scope uniqueness (e.g. tenant_id, parent_id).
reserved [] Slugs that may never be assigned. Override polyslugReservedWords(array $inherited): array on the model to filter, replace or clear everything it inherits (own list + reserved.global + route-derived words); returning [] opts out entirely. Needed for a model behind a prefix like /@owner/repo, where a slug cannot shadow a route and every reservation is a false positive that silently becomes api-2.
immutable false Freeze the slug — never regenerate on source change.
encoder null Per-model IdentityEncoder class overriding the global one.
onDelete 'keep' On soft-delete: keep reserves the slug; release frees it. Hard/force delete always cascades slug rows. A delete by query fires no event: call Model::polyslugForget($keys) with its keys, which deletes the slug rows and keeps the short links and tokens taken without naming the records.
emptyFallback 'id-only' A source with no sluggable characters (CJK/emoji-only): id-only stores an empty slug (URL is _{id}) so the save never fails, and on an idLess model makes the encoded id the slug; throw raises CouldNotGenerateSlug.
encoderOptions [] Per-model encoder settings — a dedicated token space. Sqids: alphabet, min_length. RandomTokenEncoder / SequentialTokenEncoder: length, alphabet; the one left out keeps the application's setting. A key the effective encoder does not understand is ignored.
unicode 'ascii' native keeps Unicode letters/numbers (non-Latin markets); slugs are lower-cased at generation so the case-insensitive unique index is consistent on PostgreSQL and SQLite.
preserveCase false Store the slug as written (Octo-Org) instead of folding it. Display only: uniqueness and resolution already compare case-insensitively, so nothing about collisions or lookups changes. Goes with idLess (that is where the case reaches the URL). Refused with unicode: 'native' — lower() disagrees across engines on non-ASCII, so an unfolded native slug would collide on one and not another.
idLess false Drop the _{encodedId} suffix — the URL is the slug alone; resolution is by slug (see Slug-only).
reclaim false Only with idLess. Releases a retired slug so another record may claim the name. Refused without idLess.
slugless false Drop the slug — the URL is the encoder token alone (/lists/k3f9dlq7). The mirror image of idLess; setting both is refused, as are source, maxLength and reserved, none of which do anything without a slug. Renaming the record cannot change its URL, and URLs published before the switch keep resolving and 301 to the short form.
reclaimActive false Only with reclaim. Also takes a name another record still holds actively: the holder's row is retired and the newcomer gets the name. The displaced record is then left with NO current slug until its own source is synced — listen for SlugReclaimed. Refused without reclaim.

For dynamic (per-tenant/runtime) config, implement Polyslug\Contracts\ConfiguresPolyslug and return a PolyslugConfig from polyslug() — it overrides the attribute.

config/polyslug.php

encoder, random_token.{length,alphabet} and sequential_token.{length,alphabet} (the stored-token encoders — length is a floor, a width whose space fills up widens by a character rather than failing to issue a URL), short_links.{scheme,length,alphabet} (the /go token, its own space; scheme is random or sequential, a null length takes the scheme's default of 10 random / 1 counted), sqids.{alphabet,min_length}, legacy_decoders (previous encoders to try on a decode miss — encoder migration), write.max_attempts, locale.{source,route_param,missing,fallback_locale}, reserved.{global,from_routes}, redirect.status (the self-heal status, 301 by default), gone.{status,redirect_status}, retired.status, analytics.enabled, backfill.{connection,queue,tries,timeout} (where polyslug:backfill --queue puts its jobs; all null by default, --on-queue=/--on-connection= override per run — a backfill left on the default queue blocks every password reset behind it), open_graph.locale_map (bare locales get NO og:locale — the format is language_TERRITORY, and the territory is not the package's to invent; map en => en_US to get one), sitemap.{types,max_urls,max_bytes} (the last two are the protocol's per-file ceilings, 50,000 URLs and 50 MB — past either, the command splits and writes an index), types (polymorphic registry).

Five keys also read the environment, so a host can tighten them without publishing the file: POLYSLUG_SQIDS_MIN_LENGTH, POLYSLUG_WRITE_MAX_ATTEMPTS, POLYSLUG_BACKFILL_TIMEOUT and POLYSLUG_BACKFILL_TRIES (a value that is not a number, or one below the key's floor of 0, 1, 1 and 1, keeps the default) and POLYSLUG_REQUIRE_SCOPE (compared against true, so write true; 1 does not enable it).

Leak-safe identity encoders

IdentityEncoder::encode(int|string): string / decode(string): int|string|null (null → 404, never a fuzzy match). Built in: RandomTokenEncoder (the default — unguessable random token in polyslug_tokens, leak-free for integer keys), SequentialTokenEncoder (same store, filled by counting: the shortest token not yet taken — 0, 1, … z, then 00 — so the shortest URL there is, and completely predictable, which is the trade), SqidsEncoder (obfuscation, not security: reversible, and it leaks the primary key, creation order and growth rate; it needs the bcmath or gmp extension), UuidEncoder, UlidEncoder (leaks creation time), RawIdEncoder (raw PK — internal only). Non-canonical tokens (wrong length, leading zeros, re-encoded alias) resolve to a clean 404, so each record has exactly one canonical URL. Migrate encoders without breaking links by listing the previous encoder in polyslug.legacy_decoders.

A custom encoder that hits a store per key should also implement BulkIdentityEncoder (encodeMany(list<int|string> $ids): array<string, string> — a token per key, keyed by the string form of the id, so a caller can look one up with (string) $model->getKey() regardless of whether the key was an int) — a second interface extending IdentityEncoder, so an encoder you already wrote keeps satisfying its contract untouched. It is what makes polyslugPreload() do anything: without it the preload skips your encoder silently. Its result must equal encoding one key at a time — same tokens, same collision handling — so it optimizes round trips and never the guarantees.

Routing & self-healing

Route::polyslug('/pages/{page}', [PageController::class, 'show']); // wires SubstituteBindings + polyslug.canonical in order

On a safe (GET/HEAD) request whose slug is stale, polyslug.canonical issues a redirect to the current canonical URL. For /{locale}/... routes, set polyslug.locale.source = 'route' so the middleware compares the route's {locale} segment (not the ambient app locale) and avoids wrong-language 301 loops. polyslugRouteKeyForLocale($locale) builds a key for an explicit locale (safe in queues/CLI).

The application answers first. The middleware works out what it would say, then runs the route action and replaces only a successful response. If the action refuses — abort(403), abort(404), or a returned non-2xx — that refusal reaches the client with no Location header. This matters because the header of a canonical redirect carries the resolved row's slug, i.e. usually its title, and a model on the open default polyslugResolveQuery() resolves any slug to any row. Ordering ->middleware('can:...') after the macro does NOT come earlier — Laravel's priority sort leaves it behind polyslug.canonical. The cost: a request that ends in a redirect runs the action first and discards its response, so a view counter counts it.

Nested routes under scopeBindings() (/owners/{owner}/pages/{page}) need use Polyslug\Concerns\ResolvesSluggableChildren; on the parent model. Laravel binds the child through the parent's relation by comparing the raw route value with the key column, which a slug_token never matches (404). The trait resolves the value like the child's own binding and narrows the relation, so another owner's page still 404s. It cannot sit on the child: HasUuids and HasUlids define the child-side method, and the trait methods would collide. A route declared ->withTrashed() binds soft-deleted records, scoped or not, through the same decoding.

A parameter that names a field ({page:uuid}) binds by that column, through the resolution gate, and route() builds its URL from the same column. The canonical middleware never redirects such a parameter to a slug. The slug-and-token key does not resolve there.

Rendering lists: eager-load slugs

$pages = Page::query()->with('slugs')->paginate();   // links now cost no query per row

currentSlug(), polyslugRouteKey(), slugLocales() and everything on top of them (polyslugUrls(), hreflangLinks(), hreflangTags(), sitemapAlternateTags(), Head::polyslug()) read the loaded collection. Writes never do — they always re-read the current row — and slugHistory() keeps querying, because history lives in the non-current rows a narrowed eager load would omit.

Add Page::polyslugPreload($pages) after the query to remove the remaining per-row token read of the default store-backed encoder. It is a no-op on Sqids/UUID/ULID/raw-key, so write it unconditionally — with both in place the page issues no query per row.

Resolving many identifiers (an API that receives a list of public ids): Post::polyslugResolveMany($values) returns value => Post, decoding the tokens in one query per thousand and loading the records through polyslugResolveQuery() in one query per thousand keys. Each value may be slug_TOKEN or the bare token; unknown, foreign-type and gated values are absent. Slug-only models resolve one value at a time. The bulk encoder contract is BulkIdentityDecoder (decodeManyWithin()).

Multi-tenant / draft isolation (required contract)

Override polyslugResolveQuery() to constrain which rows a slug may resolve to. Enforced across bound routes, the polymorphic resolver, slug-only, and short links. A row outside the scope resolves to a 404 indistinguishable from a nonexistent one (no existence oracle).

public function polyslugResolveQuery(Builder $query): Builder
{
    return $query->where('tenant_id', tenant()->id)->where('published', true);
}

polyslugIsRoutable(?string $locale = null): bool keeps unpublished models/locales out of hreflang sets and sitemaps.

Multilingual & hreflang

One slug per locale; the reciprocal hreflang set is built from the same resolver as the canonical URL, so they cannot drift.

$page->setSlug('de', 'Hallo Welt');
$page->hreflangTags(fn (string $locale, string $key) => route('pages.show', [$locale, $key]));

Or in Blade: @polyslugHreflang($page, $resolver).

A locale code is stored in 16 characters: language, script and region fit (sr-Latn-RS), a code with a Unicode extension (de-DE-u-co-phonebk) does not, and is refused with an InvalidArgumentException before any slug or short link is written.

If one slug is served under several addresses, say so — nothing else can infer it. The hreflang set and the sitemap are both built from slugLocales(), which answers "which locales hold slug text". Where a project pins each slug to one locale on purpose and still routes every record under a locale prefix (/u/lena and /de/u/lena), that list has one entry forever, and the second address is announced nowhere. Nothing fails — it is never discoverable, and hreflang cannot rescue it because a crawler reads that only after fetching the page.

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

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

Opt-in, like BulkIdentityEncoder: without the interface nothing changes. The locale with no slug of its own reuses the default locale's, which is what lets one slug serve both addresses.

laravel/head (optional) {#laravel-head}

If laravel/head is installed, let it own the <head> instead of rendering tags yourself. Head::polyslug($model) writes only what Polyslug is the authority on — canonical URL (from the bound PolyslugUrlResolver, not the request), the reciprocal hreflang set, og:locale (only in language_TERRITORY form — see open_graph.locale_map), plus one og:locale:alternate per other locale, and a robots directive when polyslugIsRoutable() is false. Title, description, cards and JSON-LD stay yours.

A site-wide robots hint goes through Head::defaults(), never after Head::polyslug(). laravel/head replaces a string tag, so Head::polyslug($gated)->robots('max-image-preview:large') erases the gate's none and publishes a page the app is hiding — nothing red, nothing logged. A default applies as the base and loses to Polyslug, which is the outcome you want.

That directive defaults to none, which per spec means noindex, nofollow — a stronger claim than the gate makes, since the gate is about indexability and says nothing about the links on the page. Override polyslugRobotsDirective(): string|array on the model to answer ['noindex', 'follow'] instead, which is usually what a draft or a gated preview wants ([RobotsRule::NoIndex, RobotsRule::Follow] from laravel/head works the same). The answer must still contain noindex or none; anything permissive, and anything empty, throws MisconfiguredPolyslug rather than quietly un-gating the page. Say nothing and nothing changes.

laravel/head needs Laravel 13.17+ — its own floor, not Polyslug's, which is 13.0. On an application pinned below that, Composer refuses the install; everything else in Polyslug works unchanged.

Head::polyslug($article)->title($article->title)->description($article->excerpt);
Head::polyslug($article, $request->route('locale')); // on a /{locale}/... route

Two rules. Pick one hreflang path — Head::polyslug() OR @polyslugHreflang, never both, or the page carries two identical alternate sets. And Head::canonical() alone is not enough: it falls back to the request URL, so on a route without polyslug.canonical a stale slug renders and that tag names the outdated URL as canonical.

Requires a bound PolyslugUrlResolver for the URL tags; without one it writes only the robots directive rather than guessing a URL shape.

Route::polyslug() works at both chain positions — bare, and after middleware(), prefix() or name().

Nested (hierarchical) slugs

Override polyslugParent() to compose ancestor slugs into the path (/electronics/phones/iphone_TOKEN); scope uniqueness on the parent key. Paths are computed from ancestors' current slugs, so a rename/reparent self-heals via the canonical redirect (no cascade). Route with a catch-all: Route::polyslug('/{category}', ...)->where('category', '.*').

Slug-only URLs (idLess)

#[Polyslug(idLess: true)] drops the id suffix. Resolution is by slug: current resolves directly, a superseded slug 301s to the current URL, and retired slugs stay reserved so an old URL can never point at a different model. The slug must be unique per (type, locale, scope).

Retired slugs stay reserved — a later record asking for a freed name gets a suffix. Set reclaim: true (idLess only) to release it instead: the newcomer takes the name and the URL serves the new owner. Use it only when an external source already reassigns the name (a mirror, an upstream registry); on app-owned slugs it turns a rename into a way to take over somebody else's published URL.

reclaim frees only a retired name. A name another record still holds actively keeps blocking, which is the state a mirror lands in when its upstream events arrive out of order: upstream renames A to y and gives x to B, B's delivery arrives first, and B is named x-2 forever while the source says x. Add reclaimActive: true to take the name from the live holder — its row is retired inside the same transaction, so the name is never owned by nobody, and its old URL still resolves. The displaced record then has no current slug for that locale until its own source is synced; the package cannot know what it should be called instead. Listen for Polyslug\Events\SlugReclaimed (claimant, locale, slug, previousOwnerType, previousOwnerId) and re-sync from there. It dispatches after commit, like SlugChanged: inside your own transaction it waits for the commit, and a rollback drops it.

On a scoped model the lookup needs the scope handed to it. Uniqueness is per scope, so two records may hold the same slug (/@alice/toolkit, /@bob/toolkit); a slug-only read has only the string. Override polyslugResolutionScope(): ?array to return ['owner_id' => …] — the lookup is then filtered by the key the write path stored. Set polyslug.resolution.require_scope to refuse a scoped lookup that names no scope instead of returning whichever row sorts first.

Sitemaps & short links

  • Bind Polyslug\Contracts\PolyslugUrlResolver first — one method, model+locale → absolute URL, the one class the app writes itself because the package cannot know its routes. It feeds sitemaps, short links AND the laravel/head tags. Skipping it is the most common setup mistake, and two of the three fail silently: every /go link returns 404 (identical to an unknown token, so the route is not an existence oracle) and the head tags are not written. Only polyslug:sitemap names the contract. polyslug:doctor reports the binding.
  // AppServiceProvider::register()
  $this->app->bind(PolyslugUrlResolver::class, fn () => new class implements PolyslugUrlResolver {
      public function url(Sluggable $model, string $locale): string
      {
          return route('pages.show', ['locale' => $locale, 'page' => $model->polyslugRouteKeyForLocale($locale)]);
      }
  });

Use polyslugRouteKeyForLocale($locale), not getRouteKey() — the latter answers for the current locale and would emit the same URL for every language.

A model with no public address: add an optional canAddress(Sluggable $model): bool to the resolver and return false for it. url() returns a string, so without that channel the only way to refuse is to throw — which polyslug:sitemap survives (the record is skipped, the type named at the end) but reports as an accident. A declared refusal is silent. The method is not on the interface, so an existing resolver needs no change.

  • php artisan polyslug:sitemap --path=public/sitemap.xml — streams all polyslug.sitemap.types with hreflang alternates, honoring polyslugIsRoutable(). A type the resolver cannot address costs that type, never the document: one throw used to end the whole run, and a red scheduled run leaves the stale file in place. A database error is not a skip: it fails the run (non-zero exit). Files are written under temporary names and renamed into place only when the whole set is complete, so a failed run keeps the previous sitemap. One <url> per address, so a record served under three locales is three entries, each carrying the same alternate set. Past 50,000 URLs or 50 MB it writes sitemap-1.xml, sitemap-2.xml, … beside --path and a <sitemapindex> at --path; the index needs absolute URLs, so it takes app.url or --base-url= and fails rather than writing relative ones. <lastmod> is emitted only for a model that implements polyslugLastModified(): ?DateTimeInterface (on the trait, not the contract); it returns null by default because a timestamp that moves on every write gets the field disregarded site-wide. <priority>/<changefreq> are never emitted — engines ignore them.
  • $model->shortLink() + route ShortLinkController at /go/{token} — a stable token that 301s to the current canonical URL (survives renames).

Other operations

  • Gone/supersede: polyslugSupersededBy() 301s to a successor; polyslugIsGone() returns a configurable 410.
  • One old address must stop leading to a record (a rename forced by a trademark complaint): $model->retireSlug('old-slug') makes that former slug answer retired.status (410) instead of redirecting; other former slugs keep redirecting. Only a former slug can be retired (the current one throws), and it needs polyslug.canonical like self-healing. The successor is re-resolved through polyslugResolveQuery() before its route key is rendered, so a successor the requester may not see produces no redirect — you do NOT need to filter inside polyslugSupersededBy(). Use polyslugResolveSelf() for the same check on any model you obtained outside a resolution path and are about to disclose.
  • Seeding vs claiming on a reclaimActive model. setSlug()/polyslugSync() take a name another record still holds — right for a webhook, where the source already handed it over. seedSlug()/polyslugSeed() yield — right for a backfill, where two existing records wanting one name is a conflict in the data, not a handover. polyslug:backfill seeds. Identical on a model that is not reclaimActive, so seeding is safe to use unconditionally.
  • Backfill: php artisan polyslug:backfill "App\\Models\\Page" [--locale=de] [--queue] [--chunk=N]. The model class is a required argument, not an option — the command backfills one sluggable model at a time.
  • Orphans: php artisan polyslug:forget-orphans [--dry-run] [--type=…] forgets the slugs, short links and tokens of every key whose record is gone (a foreign-key cascade, a delete by query that never called polyslugForget()), asked without global scopes, so a trashed record keeps its rows. Schedule it daily where foreign keys cascade.
  • Analytics: polyslug.analytics.enabled fires a SlugRedirected event on each self-heal.
  • Diagnostics: php artisan polyslug:doctor checks the encoder and token configuration, the model replacements in polyslug.models, the four status settings, the uniqueness indexes and the URL resolver, and reports every type registered in polyslug.types that still resolves through the open default polyslugResolveQuery(), the types on which any slug resolves to any row.

Testing

Use the InteractsWithPolyslug trait in a test case:

$this->assertHasCurrentSlug($page, 'hello-world');
$this->assertSlugResolves(Page::class, $page->getRouteKey(), $page->id);
$this->assertSlugRedirects('/pages/old-slug_TOKEN', '/pages/new-slug_TOKEN');
$this->assertSlugNotResolvable(Page::class, 'bad_token');

Gotchas

  • Add polyslug.canonical (or the Route::polyslug macro) or renames won't self-heal.
  • Without polyslugResolveQuery(), a shared slug can resolve across tenants — always scope it in multi-tenant apps.
  • The gate is not the scope. polyslugResolveQuery() filters by what the environment says is visible (session, tenant). A scope living in the URL (/@owner/repo) never reaches it, so on a slug-only model it separates nothing — use polyslugResolutionScope() for that.
  • The slug never identifies the model; binding uses the encoded id. A wrong slug still resolves, then redirects.
  • idLess requires globally-unique slugs per scope; retired slugs stay reserved by design.
  • maxLength never shortens the token. A long URL is usually the token, not the slug: set polyslug.random_token.length (default 16), or encoderOptions: ['length' => 8] for one model. Both are floors, not fixed widths.
  • polyslug_tokens is keyed by (morph type, id) since 0.11.0 — Page#1 and Wishlist#1 hold different tokens, and a token addressed to another model type resolves to null. Upgrading runs a migration that backfills each token's owner, so published URLs survive; skipping it would mint a new token for every record. Scope polyslugResolveQuery() on anything that is not public regardless — it answers may this viewer see this record, which token separation does not.
  • A counted token is numbered when its URL is first built, not when the record is saved. Ordinary traffic numbers records roughly in creation order; a bulk import that never renders a link numbers nothing. Use polyslugPreload() over the records in the order you want.
  • Run polyslug:doctor after changing a token length or alphabet: an unbuildable one otherwise first refuses while a URL is being rendered, in production.
  • Native Unicode mode assumes NFC-normalized input.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console