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.

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.