Skip to main content

Database

Two migrations ship with the package and are registered automatically, so php artisan migrate is enough. See Installation.

polyslug_slugs

Every slug every model has ever had. The current one is flagged is_current; the rest are kept as history so no published URL goes dead.

ColumnTypeNotes
idauto-increment
sluggable_typestringMorph type.
sluggable_idstringMorph key, stored as a string so any host key type fits — integer, UUID, ULID, token.
localestring(16)One slug per locale.
scopestringThe uniqueness scope; '' means global per type and locale.
slugstringThe slug itself.
is_currentbooleantrue for the live slug, false for history.
enforce_uniquebooleanDefaults to true, so every existing slug keeps its guarantee. false for rows written by a unique: false model, which excludes them from the uniqueness index. Id-less models always stay true — they resolve by slug.
created_at / updated_attimestamps
deleted_atsoft delete

The two unique indexes

  • polyslug_slugs_current_unique over (sluggable_type, locale, scope, lower(slug)) for current, non-deleted, uniqueness-enforcing rows. This is the slug-uniqueness guarantee, and it is case-insensitive.
  • polyslug_slugs_one_current over (sluggable_type, sluggable_id, locale, scope) for current, non-deleted rows. This is the one-current-slug-per-record guarantee — without it a concurrent rename could leave two live rows and flap the canonical URL.

They are independent, which is why unique: false can relax the first without touching the second.

The mechanism differs per engine, the guarantee does not:

  • PostgreSQL and SQLite use a functional partial unique index — the same statement runs on both.
  • MySQL 8.4 has neither partial nor functional-partial indexes, so the migration adds VIRTUAL generated key columns that are NULL for non-current or soft-deleted rows (nulls never collide in a unique index), hashed to stay inside the index key-length limit, with LOWER(slug) mirroring the other engines' lower(slug).

polyslug_tokens

The key-to-token store for the stored-token encoders, RandomTokenEncoder and SequentialTokenEncoder. One row per record, with (key_type, key_value) and token both unique — that uniqueness is what makes a record's short opaque URL stable.

key_type is the morph type that owns the token, so Page#1 and Wishlist#1 hold separate rows and separate tokens. An empty key_type is the untyped lane: tokens issued before 0.11.0 that the upgrade could not attribute, plus anything an encoder written against the older IdentityEncoder contract stores. Reads fall back to it, so those URLs keep resolving; the store adopts such a row the first time the record it belongs to renders a URL.

The column is NOT NULL with an empty default rather than nullable, and that is deliberate: nulls do not collide in a unique index on any of the three engines, so a nullable owner would let two rows hold the same key_value and reintroduce — inside the untyped lane — exactly the ambiguity the column removes.

Tokens are stored rather than computed, which is what makes a length or scheme change safe on a live application: existing rows keep resolving unchanged, so the table legitimately holds a mix of widths.

Empty and inert unless a model uses one of those encoders.

The /go short links: one row per (model, locale) with a unique token, plus a unique constraint on the target triple so a model and locale can never end up with two competing tokens.

Empty and inert until shortLink() is called.

Rolling back

Both migrations are reversible. The second one restores the unconditional current_unique index before dropping the enforce_unique column it referenced; the first drops the three tables in dependency order.