How it works
The route key
A route key is composed of two parts joined by an underscore:
laravel-routing-explained_aB3xK
└──────── slug ─────────┘ └id─┘
- The slug is generated from the source column(s), transliterated and normalized. It is what humans and search engines read — and it is free to change.
- The id is an opaque token produced by an identity encoder from the model's primary key. It is what actually resolves the model.
The underscore is a reserved delimiter: it is never valid inside a slug, so a value is split on its last occurrence. That makes the split unambiguous even for a value that illegally contains the delimiter — the identity is still found, and the canonical redirect corrects the URL.
A slug itself is lowercase alphanumerics in single-separator-separated words. In the
default ascii mode anything else is transliterated away; see
Transliteration and Unicode for the native
mode that keeps non-Latin letters.
Resolution is by id, not by slug
Because binding resolves the id, not the slug, an outdated or wrong slug still finds
the correct model. The polyslug.canonical middleware notices the slug no longer matches
and redirects to the canonical URL.
An unknown or malformed token yields a clean 404 — never a fuzzy match, and never a
guess at a nearby key. Non-canonical tokens (a wrong length, leading zeros, a re-encoded
alias) are rejected for the same reason: every record must have exactly one canonical URL.
Id-less models invert this — there the slug is the identity, which is why it has to stay unique.
Nothing you published ever goes dead
Every slug a model has ever had is stored in the polyslug_slugs table (the current one
flagged is_current, the rest kept as history), so no URL you have ever published goes
dead. Read the history with
slugHistory().
Writes are concurrency-safe
Demoting the old slug and inserting the new one happen in one transaction, and a partial
unique index guarantees exactly one current slug per (type, id, locale, scope).
A racing writer that claims the slug first simply causes a bounded regenerate-and-retry:
Polyslug regenerates against the committed state and retries up to
polyslug.write.max_attempts before throwing
Polyslug\Exceptions\CouldNotWriteSlug. On failure the model keeps whatever slug it had
before — never a duplicate, and never a slug-less model. It gets there by restoring the row
it demoted, inside a transaction that always commits; nothing is rolled back, because a
nested savepoint is unreliable once DDL has implicitly committed an outer transaction.
The uniqueness guarantee is enforced by the database itself, differently per engine but equivalently: a functional partial unique index on PostgreSQL and SQLite, generated key columns on MySQL 8.4. The package's own suite runs against all three.