Self-healing routes
Bind a model as usual and add the canonical-redirect middleware. URL generation uses the canonical route key automatically:
Route::get('/pages/{page}', [PageController::class, 'show'])
->middleware('polyslug.canonical')
->name('pages.show');
route('pages.show', $page); // → /pages/my-title_aB3xK
The Route::polyslug macro
polyslug.canonical must run after SubstituteBindings, or the model is not bound
yet and self-heal silently does nothing. The Route::polyslug() macro wires both in the
correct order for you:
Route::polyslug('/pages/{page}', [PageController::class, 'show'])->name('pages.show');
That is the safer default: a mis-ordered middleware stack produces no error, just a feature that quietly stopped working.
What gets redirected
On a safe (GET or HEAD) request whose slug is stale, polyslug.canonical issues a
redirect — 301 by default, configurable via
polyslug.redirect.status — to the canonical
URL. Use 302/307 while a slug is still volatile so the redirect is not cached. 308
is accepted as well, but only GET and HEAD requests are ever redirected, so its
method-preserving guarantee changes nothing here.
A trailing slash is redirected too, and for the same reason: /blog/hello_aB3xK/ and
/blog/hello_aB3xK are two addresses serving one document. The router matches both against
the same route with an identical slug parameter, so nothing about the slug is stale — the
duplicate is the path itself.
The redirect:
- preserves the query string — every parameter and value arrives, with the keys sorted and re-encoded by the framework;
- rebuilds every route parameter, so a multi-parameter route redirects to a fully canonical URL rather than a half-corrected one;
- rebuilds for the request's locale, not the ambient application locale — see Multilingual slugs and hreflang.
What is left untouched:
- Unsafe verbs.
POST,PUT,PATCH,DELETEpass straight through — a form submission is never bounced. - Non-sluggable segments. A route parameter that is not a
Sluggablemodel is carried over unchanged. - Requests whose slug already matches. No redirect, no cost beyond the comparison.
Gone and superseded content is terminal: it is evaluated
before same-model self-heal, so a removed record returns its 410 and a superseded
one redirects to its successor rather than to its own canonical URL.
Set polyslug.analytics.enabled to dispatch a
SlugRedirected event on every
self-healing redirect.
The application answers first
A redirect never overtakes your own authorization. When the middleware finds a stale slug,
a superseded model or a withdrawn one, it works out what it would answer, then runs the
route action and only replaces a successful response. If the action refuses — abort(403),
abort(404), a returned 403, or any non-2xx — that refusal is what the client gets, with
no Location header attached. A redirect the action issued itself is likewise left alone.
This is not defensive plumbing; it closes a disclosure that nothing else could:
- The
Locationheader of a canonical redirect is built from the resolved row's slug, and a slug is usually the title. A model whose resolution gate is still the open default resolves any slug to any row — so answering before the action handed out that row's title to a request the application would have refused. - Middleware order cannot fix it.
Route::polyslug()wires the binding and the canonical middleware into the route, and Laravel's priority sort does not lift an unprioritizedAuthorizein front of them — so even->middleware('can:...')ran too late. Authorization inside the action had no ordering escape at all. - The same applies to gone and superseded models: the successor
redirect disclosed the successor's title, and a
410told an unauthorized caller that the row exists and was withdrawn, which a403or404does not.
What it costs. On a request that ends in a redirect, the route action now runs and its
response is discarded. A GET action should have no side effects, but "should" is the
operative word — a view counter will count a request that ends in a 301. That trade is
deliberate: a redirect that overtakes an authorization is the more expensive mistake.
Resolution and 404s
Route-model binding resolves the model by decoding the id; an unknown or malformed token
yields a 404, never a fuzzy match. See How it works.
Which rows a valid token is allowed to resolve to is your application's decision — see Access control.
Declaring the canonical URL in the page
The redirect fixes the address; it does not write the <link rel="canonical"> tag. If the
application uses laravel/head, Polyslug can write it from the same
resolver that builds the sitemap — which matters most on a route that does not carry
polyslug.canonical, where a stale slug renders instead of redirecting and a request-derived
canonical tag would name the outdated URL.