Skip to main content

Database

The package's migrations are registered automatically, so php artisan migrate is enough. Installation lists what each one does.

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.
retired_atnullable date and timeWhen a former slug was retired. A request for it answers polyslug.retired.status instead of redirecting.
created_at / updated_atnullable date and time
deleted_atnullable date and timeThe soft delete.

On MySQL every time column of the three tables is DATETIME, not TIMESTAMP: a TIMESTAMP ends at 2038-01-19, and MySQL converts it through the session's time zone on every read and write. PostgreSQL stores timestamp without time zone and SQLite text, which behave as DATETIME does.

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). The five string columns those keys read (sluggable_type, sluggable_id, locale, scope and slug) are pinned to the binary collation of their character set, so a lookup compares names the way the keys do. Left to the connection's collation, which ignores accents and case by default, café and cafe would be one name to a lookup and two to the keys.

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.

On MySQL key_type and key_value compare byte for byte, like token. Left to the connection's collation, which ignores case by default, two records keyed Abc and abc would be one key to the unique index and share a token.

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. On MySQL the triple compares byte for byte, so two records keyed Abc and abc get a short link each.

Empty and inert until shortLink() is called.

Rolling back​

Every migration is reversible. Rolling back runs them in reverse: the key columns get back the collation they inherited from their table, retired_at comes off, the reordered resolution index goes back to its first form, the token collation pin and that index come off, then the token scoping, then the enforce_unique migration -- which restores the unconditional current_unique index before dropping the column it referenced -- and finally the first one drops the three tables in dependency order.