Skip to main content

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.