Diagnostics
Verify the setup — the encoder configuration, the model replacements, the status settings, the token settings, how much room the token spaces have left, and the uniqueness-guaranteeing indexes — at any time:
php artisan polyslug:doctor
It exits non-zero when a check fails, so it works as a deployment smoke test.
The checks
Encoders. The configured polyslug.encoder and every entry in
polyslug.legacy_decoders must be an existing class implementing
Polyslug\Contracts\IdentityEncoder. A typo in a class name, or a legacy decoder left
behind after a package was removed, is reported per class:
✗ [App\Encoders\Typo] is not a valid IdentityEncoder.
Each one is then built the way a request builds it, because a valid class can still refuse
to be constructed. SqidsEncoder does on a PHP that has neither the bcmath nor the gmp
extension, and the line carries the reason:
✗ [Polyslug\Encoders\SqidsEncoder] cannot be built: Missing math extension for Sqids, install either bcmath or gmp.
Model replacements. Every entry in polyslug.models must be keyed by a model Polyslug
replaces, Polyslug\Models\PolyslugSlug or Polyslug\Models\PolyslugShortLink, and name an
existing class that extends it and can be instantiated. Polyslug ignores any other entry and
keeps its own class, so requests keep working while your subclass's scopes, casts and
relations never run. The doctor names each such entry and the class Polyslug uses instead:
✗ polyslug.models: [App\Models\Slgu] for [Polyslug\Models\PolyslugSlug] does not exist, so Polyslug uses [Polyslug\Models\PolyslugSlug].
An empty map has nothing to check, and a map whose entries are all in use is confirmed.
Status settings. redirect.status and gone.redirect_status must be one of 301, 302,
303, 307 or 308, and gone.status and retired.status a client error from 400 to
499. Polyslug sends the default for any other value, so every request still answers, and the
doctor names the value it ignored:
✗ polyslug.redirect.status: [200] is not a redirect status (301, 302, 303, 307 or 308), so Polyslug redirects with 301.
Uniqueness indexes. Both polyslug_slugs_current_unique and
polyslug_slugs_one_current must exist on polyslug_slugs, or, when polyslug.models
replaces the slug model, on that model's table and connection, since every slug is written
there. A missing index almost always means the migrations never ran on this database:
✗ unique index [polyslug_slugs_one_current] is missing — run the migrations.
This one is worth taking seriously. Both guarantees are enforced by the database, not by application code, so a missing index does not fail loudly — it just quietly stops preventing duplicate or double-current slugs until the day two writes race. See the database reference.
Token schemes. The lengths and alphabets under polyslug.random_token,
polyslug.sequential_token and polyslug.short_links must be able to build a scheme — a
length of zero, a length past what counting can reach, a repeated character, a / in an
alphabet:
✗ A random token length must be between 1 and 255; got [0].
Worth running after any change to those settings. Without this check the refusal arrives the first time a URL is rendered — on a page, in production, for a setting that shipped with a green test suite, because nothing in a test suite renders a URL for a record that does not exist yet.
Token settings of the wrong kind. A length is read only as an integer and an alphabet only as
a string, under polyslug.random_token, polyslug.sequential_token, polyslug.short_links and
polyslug.sqids, and in the encoderOptions of every model registered in polyslug.types. Any
other value falls back to its default, so the scheme still builds and every request still answers,
and the setting changes nothing. '24' is what env() hands over, so a length moved into
the environment without a cast is the usual way in. The doctor names each such value and what
Polyslug uses instead:
✗ polyslug.random_token.length: ['24'] is not an integer, so Polyslug uses 16.
✗ [App\Models\Post] encoderOptions['length']: ['24'] is not an integer, so Polyslug uses the application's length.
A model's options are checked only where its encoder reads them: length and alphabet for a
stored-token encoder, which falls back to the application's own setting, and min_length and
alphabet for Sqids, which falls back to the Sqids defaults. An unset value is the default and
says nothing.
A Sqids model whose options leave out an alphabet or a padding that polyslug.sqids sets gets a
! line instead. Its space is built from its own options alone, so it takes the Sqids default
for the one it leaves out, and every URL it has issued was encoded that way. That is a valid
choice, so the doctor names it and does not fail:
! [App\Models\Post] encoderOptions leave out 'alphabet', so it takes the Sqids default instead of polyslug.sqids.alphabet.
Token space. How full each token width is, reported per width because a table that has outlived a setting change legitimately holds several:
! identity tokens: 400 of 1,296 2-character tokens are taken (31%).
New tokens widen to 3 characters as this fills.
This is a report, not a failure, and quiet below 25%. A width filling up is not a
fault — a scheme that runs out yields to one character more — but the URLs quietly get
longer, and if you picked four characters for a printed code you want to hear about it
before the printed codes stop matching the new ones. On a
counted scheme a high number is
what is supposed to happen, since counting fills a width completely before moving
on. Short links are counted on the table and connection of the short-link model in use, which
polyslug.models can replace.
Resolution gates. Every type in polyslug.types that never overrode
polyslugResolveQuery() is named:
! [App\Models\Consultation] does not override polyslugResolveQuery().
Any slug of those types resolves to any row.
This is a report, not a failure, and the command still exits zero. A model that
resolves globally is a legitimate choice for public content; the same model behind an
owner check is an authorization gap, because a stale-slug request for someone else's row
is answered with a 301 whose Location header carries that row's slug. The command
cannot tell the two apart — it makes the choice visible so it stops being accidental. If
global resolution is what you want, override the gate with return $query; and the line
goes away, now as a recorded decision.
See Access control.
When to run it
- After deploying to a new environment, before the first traffic hits it.
- After changing the encoder, a token length or an alphabet, or editing
legacy_decoders. - Periodically on a short token setting, so a width that is filling up is visible before it fills.
- After adding a sluggable model, to see whether it needs a resolution gate.
- After a manual schema change on
polyslug_slugs— an index dropped during an unrelated migration is exactly what this catches.
What it does not check
polyslug:doctor validates configuration and schema, not data. It will not tell you that
a model is missing its #[Polyslug] attribute -- that throws
MissingPolyslugConfig on first use.
It does report an unbound PolyslugUrlResolver, with a line naming what stops working
without one. That report is informational: it does not make the command exit non-zero,
because a package that renders no URLs needs no resolver. The
sitemap command is the one that refuses
outright, since it cannot produce output at all without it.