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. It keeps the request's query string as well, the same way the self-heal redirect does: a campaign parameter on the old address arrives on the successor's.

Chains and rings​

A successor can be superseded in turn. The redirect follows the chain and names its last record, so a page that moved twice costs the visitor one redirect, not two. Each record on the way passes the gate, and the first one the requester may not see ends the chain at the record before it.

A chain that comes back to a record it already passed would send the browser between those addresses forever. Two records merged by hand, each naming the other, are the usual case. Such a ring counts as no successor at all, and each of its pages is served, the same as a record that names itself.

The redirect only ever names the end of a chain, never a record in the middle of it, so it never lands on a page that redirects again. It follows at most 32 successors to find that end. A chain whose end lies further away is treated like a ring: no redirect, and the page is served. An answer in the middle would leave the next request to start over without knowing where the visitor came from, and on a ring longer than one answer reaches, the browser would go round it until it gave up.

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 record stays, but one of its old addresses may no longer lead to it → retireSlug().
  • The record is gone and no longer stored at all, as in a read model that drops what was withdrawn → polyslugIsGoneAddress(), asked by route binding with the token of the URL. See Gone without a record.

Retiring a single slug​

A former slug redirects to the record's current address, which keeps old links alive after a rename. Sometimes the old address must not keep pointing at the record: a rename forced by a trademark complaint, where /u/audi_aB3xK belongs to an account that is not Audi's. Retire that one slug:

$account->update(['username' => 'neutral-name']);

$account->retireSlug('audi'); // the current locale
$account->retireSlug('audi', 'de'); // or a named one

A request for the retired slug then answers 410 instead of redirecting, while the record keeps answering at its current address and every other former slug keeps redirecting. The status is polyslug.retired.status; set it to 404 if you prefer. To tell the visitor the address was renamed, render resources/views/errors/410.blade.php.

The application answers first, as for a gone record: a refusal of its own reaches the client unchanged, and only a successful response is replaced. A retired slug outranks a stale slug; a gone or superseded record outranks it, because those answer for the whole record.

Only a former slug can be retired. The current one is the record's address, so retiring it throws: rename first. retireSlug() returns how many rows it retired, 0 for a slug the record never held or one already retired, and clearing retired_at on the row reverses it. A slug-only model keeps its former slugs reserved, so the retired name stays out of reach of other records too.

The middleware asks about a retired slug only for an address that is not the current one, so a canonical request costs no query. Like self-healing, retiring works through polyslug.canonical.

The government and enterprise recipe uses both, together with immutable slugs.