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.

The redirect carries the request's query string over, the same way the self-heal redirect does, so a campaign parameter on a printed or shared link arrives on the page: /go/{token}?utm_source=flyer leads to the canonical URL with ?utm_source=flyer. Your resolver's URL is kept exactly as it comes. A parameter it sets itself keeps the resolver's value, the request adds the ones it does not set, and a fragment the resolver returns stays at the end.

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.

The rows of polyslug_short_links stay when their model is deleted, which is why a link to a deleted model answers 404 rather than reaching another one. Leave them in place in your own cleanup as well: with the sequential scheme the next link is counted from the highest row that remains, so deleting the newest row hands its token, and every copy of it already printed, to the next model that asks for a link.

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);