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.
| Column | Type | Notes |
|---|---|---|
id | auto-increment | |
sluggable_type | string | Morph type. |
sluggable_id | string | Morph key, stored as a string so any host key type fits — integer, UUID, ULID, token. |
locale | string(16) | One slug per locale. |
scope | string | The uniqueness scope; '' means global per type and locale. |
slug | string | The slug itself. |
is_current | boolean | true for the live slug, false for history. |
enforce_unique | boolean | Defaults 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_at | nullable date and time | When a former slug was retired. A request for it answers polyslug.retired.status instead of redirecting. |
created_at / updated_at | nullable date and time | |
deleted_at | nullable date and time | The 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_uniqueover(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_currentover(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
VIRTUALgenerated key columns that areNULLfor non-current or soft-deleted rows (nulls never collide in a unique index), hashed to stay inside the index key-length limit, withLOWER(slug)mirroring the other engines'lower(slug). The five string columns those keys read (sluggable_type,sluggable_id,locale,scopeandslug) 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éandcafewould 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.
polyslug_short_links
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.