Skip to main content

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.

Nothing is asked of you

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 the 301.
  • 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.