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: truewithunique: 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: truewithoutidLess: 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: truewithidLess: true— the two drop opposite halves of the URL, so together there is nothing left to route on.slugless: truewithmaxLength,reservedorsource— each of them acts on a slug, and a slugless model has none.maxLengthis 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: truewithunicode: 'native'— uniqueness folds with the database's ownlower(), 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.sourceomitted on any model that is notslugless— 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: falsewithoutunique: false, and together withidLessorimmutable— 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.identitytogether withencoder,encoderOptionsoridLess— 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.