Skip to main content

Short links

A stable short URL per model and locale that always 301s to the current canonical URL, so a printed or QR-coded link survives every slug rename.

Setup, in the order you need it

Three steps, and the second one is the one people miss — without it every short link returns 404 and nothing says why.

1. A model that uses Polyslug

use Polyslug\Attributes\Polyslug;
use Polyslug\Concerns\HasPolyslug;
use Polyslug\Contracts\Sluggable;

#[Polyslug(source: 'title')]
final class Page extends Model implements Sluggable
{
use HasPolyslug;
}

See sluggable models if this part is new.

2. A bound URL resolver — the class you write yourself

The controller has to build the URL it redirects to, and only your application knows the route a page lives on. So you supply one class implementing PolyslugUrlResolver:

use Polyslug\Contracts\PolyslugUrlResolver;
use Polyslug\Contracts\Sluggable;

// AppServiceProvider::register()
$this->app->bind(PolyslugUrlResolver::class, fn () => new class implements PolyslugUrlResolver
{
public function url(Sluggable $model, string $locale): string
{
return route('pages.show', [
'locale' => $locale,
'page' => $model->polyslugRouteKeyForLocale($locale),
]);
}
});

Bind it once and sitemaps and the laravel/head integration work too — it is the same contract. The URL resolver covers it properly: multiple model types, nested paths, and what each feature does when it is missing.

warning
Skip this step and every short link is a 404

The controller cannot guess a URL shape, so it returns 404 rather than a wrong redirect — and that 404 is deliberately identical to the one an unknown token gets, so /go cannot be used to probe which records exist. The cost is that a missing binding and a bad token look exactly alike. php artisan polyslug:doctor tells them apart.

3. Route the shipped controller

use Polyslug\Http\Controllers\ShortLinkController;

Route::get('/go/{token}', ShortLinkController::class);

The path is yours to choose — /go/{token} is only the convention. The controller is invokable, so it needs no wrapper.

$token = $page->shortLink(); // stable per (model, locale)
url('/go/'.$token); // → 301 → the model's current canonical URL

That is the whole API. The token is minted on the first call and returned unchanged afterwards.

Stability

shortLink() returns the same token for the same model and locale every time: the token is stored once in polyslug_short_links and reused. That is what makes it safe to print on a ticket, a poster, or a package insert — the target follows the model's canonical URL forever, through every rename and every locale change to the slug.

Pass a locale to mint or fetch the token for a specific one:

$page->shortLink('de');

What returns 404

  • An unknown token.
  • A token whose model no longer exists.
  • A token whose model the current request may not see. Short links resolve through polyslugResolveQuery(), so /go is not a back door around a tenant or draft gate — and the 404 is indistinguishable from an unknown token, so it is not an existence oracle either.
  • No bound PolyslugUrlResolver — step 2 above. This one is a setup mistake rather than a miss, and it is the only entry here that makes every link fail at once. If none of your short links have ever worked, start here.

The sharing and QR codes and events and ticketing recipes put this in context.

Choosing the token

A short link is its own token space with its own setting, because it is used differently from the token inside a URL: it gets printed, read out loud and put on a QR code, so it is the one most likely to want to be short.

// config/polyslug.php
'short_links' => [
'scheme' => 'random', // or 'sequential'
'length' => null, // null takes the scheme's default: 10 random, 1 counted
'alphabet' => null, // null uses 0-9a-z
],

Random (the default) draws ten characters, so the link says nothing about the record. Sequential hands out the shortest token not yet taken — 0, 1, … z, then 00 — which is the shortest a link can be, at the cost of being completely predictable. A /go link resolves through the resolution gate like every other path, so a counted link is not a way past authorization; it is a way to enumerate what exists.

length is a floor rather than a fixed width: a length whose space fills up yields to one character more rather than failing to issue a link. polyslug:doctor reports how full the space is before that happens.

To replace the scheme entirely — a checksum digit, a word list, an external service — bind Polyslug\Contracts\TokenScheme in a service provider:

$this->app->bind(TokenScheme::class, fn () => new MyScheme);