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.
A route declared with ->withTrashed() binds soft-deleted records as well, through the same
decoding and the same resolution gate. Without it, a deleted record answers 404.
A route that names a field binds by that column instead, the way Laravel binds it, and still through the resolution gate:
Route::polyslug('/pages/{page:uuid}', [PageController::class, 'show'])->name('pages.by-uuid');
route('pages.by-uuid', $page); // → /pages/1b3c…, the uuid column
The URL generator builds such an address from the same column, so the link and the binding agree. The canonical middleware leaves a parameter that names a field alone: its address is the column, not the slug, so it is never stale and never redirected to a slug.
Nested routes with scoped bindings
Under Route::scopeBindings(), ->scoped() or a parameter after a parent
(/owners/{owner}/pages/{page}), Laravel binds the child through the parent's relation and
compares the whole route value with the child's key column. A Polyslug route key is a slug and a
token, so that comparison finds nothing. Add ResolvesSluggableChildren to the parent model:
use Polyslug\Concerns\ResolvesSluggableChildren;
class Owner extends Model
{
use ResolvesSluggableChildren;
public function pages(): HasMany
{
return $this->hasMany(Page::class);
}
}
Route::scopeBindings()->group(function () {
Route::get('/owners/{owner}/pages/{page}', [PageController::class, 'show']);
});
The route value is then resolved the way the child's own binding resolves it, its
resolution gate included, and the relation is narrowed to that record: a
page of another owner answers 404. On a route declared with ->withTrashed(), the owner's
deleted pages bind too. A binding that names a field ({page:uuid}) is resolved by that
column and narrowed the same way. A child that is not a Polyslug model keeps Laravel's own
resolution. The trait sits on the parent because Laravel's HasUuids and HasUlids already
define the child-side method, and two traits defining one method cannot be combined.
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.