Gone and superseded content
Content that permanently moved or was removed should not become a soft-404 that loses its
search ranking. Two model hooks, honored by the polyslug.canonical middleware, handle
it:
use Polyslug\Contracts\Sluggable;
// Discontinued item → 301 to its replacement, preserving locale and link equity:
public function polyslugSupersededBy(): ?Sluggable
{
return $this->replacement; // any Sluggable model, or null
}
// Permanently removed → 410 Gone (a fast de-index signal; status via polyslug.gone.status):
public function polyslugIsGone(): bool
{
return $this->trashed();
}
Both default to "no" on the HasPolyslug trait, so they cost nothing until you override
them.
Precedence
Gone and superseded are terminal: the middleware evaluates them before same-model
self-heal. A removed record returns its 410 even when the requested slug is stale, and a
superseded record redirects to its successor rather than first correcting its own slug.
That is the behavior you want — correcting the URL of a page that is about to answer 410
would cost the visitor a round trip and confuse a crawler.
The successor redirect rebuilds every route parameter for the request's locale, so a localized or multi-parameter route lands on a fully canonical successor URL.
Status codes
// config/polyslug.php
'gone' => [
'status' => 410, // polyslugIsGone() → this status
'redirect_status' => 301, // polyslugSupersededBy() → this redirect
],
410 tells a crawler the removal is deliberate and permanent, where 404 leaves open
whether the address ever existed. Search engines drop either from the index, and Google
documents no faster removal for 410; the value is the explicit signal. Lower it to 404
only if something in your stack cannot deal with 410.
The successor goes through the gate too
A supersede redirect names two rows: the one the request asked for, and the one it is being sent to. Only the first has been through anything.
The requested model is gated twice over — route binding resolved it through
polyslugResolveQuery(), and
your action then answered 2xx for it. The successor is gated by neither: it arrives as a
return value from your polyslugSupersededBy(), not from a resolution. Its route key — in
practice its title — would go straight into a Location header.
That matters when a supersede relation can cross a visibility boundary: two records merged, one re-parented, a successor belonging to a different tenant. Your gate is closed, your action authorizes correctly, and a foreign title still leaves the application.
Polyslug re-resolves the successor through its own gate before naming it. A successor the gate rejects produces no redirect for that parameter — your own response is returned untouched, and the ordinary self-heal redirect for the row the request legitimately holds still runs.
Filtering inside polyslugSupersededBy() would put a security property in a method whose
signature gives no hint of it — it is named supersededBy, not supersededByIfVisible.
Deferring the answer is the only fix that protects a consumer without requiring anything of
them, which is the same reasoning behind the resolution gate itself.
The re-resolution costs one query, and only on a request that is actually superseded.
Giving the gone page its own head
The 410 is thrown as a real HttpException, which is what lets
laravel/head pick the status up: a per-status head applies
to it with no wiring on your side.
// AppServiceProvider::boot()
Head::errors(fn ($pages) => $pages->status(410, title: 'This page is gone'));
Choosing between them
- The content still exists somewhere else →
polyslugSupersededBy(). Link equity follows the301. - The content is gone for good →
polyslugIsGone(). There is nothing to point at, and a redirect to a category page is the soft-404 this is meant to avoid.
The government and enterprise recipe uses both, together with immutable slugs.