Skip to main content

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, DELETE pass straight through — a form submission is never bounced.
  • Non-sluggable segments. A route parameter that is not a Sluggable model 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 Location header 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 unprioritized Authorize in 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 410 told an unauthorized caller that the row exists and was withdrawn, which a 403 or 404 does 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.