Skip to main content

Exceptions

All five live in Polyslug\Exceptions and extend RuntimeException.

MissingPolyslugConfig

Model [App\Models\Page] uses the HasPolyslug trait but has no #[Polyslug] attribute.

The model uses the trait but declares no configuration. Add a #[Polyslug(source: …)] attribute, or implement ConfiguresPolyslug and return a config from polyslug().

MisconfiguredPolyslug

Thrown when a configuration combines options that cannot both hold:

  • idLess: true with unique: false — an id-less URL is the slug alone, so the slug must stay unique to resolve to one model. Drop one of the two.
  • reclaim: true without idLess: true — on a model whose URL carries an encoded id a retired slug is already free to reuse, so the flag would do nothing at all.
  • slugless: true with idLess: true — the two drop opposite halves of the URL, so together there is nothing left to route on.
  • slugless: true with maxLength, reserved or source — each of them acts on a slug, and a slugless model has none. maxLength is the one worth naming: it trims the slug and never the token, so on a token-only URL it would do nothing at all. The length you want is the encoder's.
  • preserveCase: true with unicode: 'native' — uniqueness folds with the database's own lower(), and PostgreSQL folds non-ASCII letters where SQLite does not. An unfolded native slug would therefore collide on one engine and not on another. unicode: 'ascii' keeps every stored slug ASCII, so every engine folds it the same way.
  • source omitted on any model that is not slugless — it had to become optional for a slugless model to declare none, and silently producing an empty slug for every record is worse than refusing.

See Slug-only URLs and Token-only URLs.

It is raised at configuration time, not at write time, so a wrong combination fails as soon as the model's config is built rather than on the first save.

CouldNotGenerateSlug

Could not generate a non-empty slug from source [🎉].

The source produced no sluggable characters and emptyFallback is 'throw'. Either switch to the default 'id-only' — which stores an empty slug so the URL is just _{encodedId} and the save succeeds — or set unicode: 'native' if the source is non-Latin text that should survive into the slug. See Transliteration and Unicode.

CouldNotWriteSlug

Could not write a slug for [App\Models\Page] from source [Hello] after repeated write conflicts.

A concurrent writer kept claiming the generated slug — or the one-current row — up to polyslug.write.max_attempts. The model keeps whatever slug it had before; it is never left slug-less or with two current slugs.

That outcome is reached by restoring the previous row, not by rolling anything back. The write path runs in a transaction that always commits: it demotes the old row, inserts the new one with insertOrIgnore, and on a lost race restores the row it demoted — inside that same committed transaction. Rolling back would mean nesting a savepoint, which is exactly what this design avoids, because a savepoint is unreliable once DDL has implicitly committed an outer transaction.

Seeing this regularly means genuine write contention on the same (type, id, locale, scope). Raise max_attempts, or look at why the same record is being saved concurrently.

There is no database exception to inspect: a lost race is a return value here, not a throw — insertOrIgnore reports zero affected rows instead of raising a unique-constraint violation, which is what makes the retry portable across engines. So getPrevious() is null, and there is nothing further underneath to read.

CouldNotIssueToken

Could not issue a random token for key [42] after 5 attempts.

Thrown by RandomTokenEncoder when it lost every attempt to claim a token for a key.

Losing a single attempt is normal and invisible: two requests rendering the same never-before-encoded model race, the loser adopts the winner's token, and both emit the same canonical URL. This exception means losing five in a row — either the polyslug_tokens table is under contention far beyond what URL rendering produces, or something else is writing to it.

It is deliberately loud. The alternative would be returning a token that was never persisted — a URL that resolves to nothing, discovered much later by whoever clicked it.