Skip to main content

Exceptions

All five live in Polyslug\Exceptions and extend RuntimeException.

MissingPolyslugConfig​

Model [App\Models\Page] uses HasPolyslug 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. A configuration handed to the slug generator alone, outside a model, may leave it out.
  • stored: false without unique: false, and together with idLess or immutable — a computed slug is written nowhere, so nothing keeps it unique, nothing can look it up by slug, and nothing could keep its first value.
  • identity together with encoder, encoderOptions or idLess — the identity column is the token, so no encoder builds one, and an idLess URL carries no token to read the column for.

Calling setSlug() or seedSlug() on a model with stored: false throws it too: the slug follows the source, and there is no row to write.

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.