Skip to main content

laravel/head integration

laravel/head is Laravel's own package for the document <head>: titles, descriptions, Open Graph, cards, JSON-LD, robots, performance hints.

It does not compete with Polyslug, and Polyslug does not compete with it. The two answer different questions:

Answers
laravel/headHow does a tag reach the <head>?
PolyslugWhat is the URL?

Polyslug emits no title, no description, no Open Graph, no JSON-LD — and no <link rel="canonical">. It knows something laravel/head cannot: which URL is the current one, which locales exist, and whether this model may be indexed at all.

This page wires the two together. It is entirely optional: without laravel/head installed, Polyslug behaves exactly as documented everywhere else.

Install

composer require laravel/head

That is the whole setup on Laravel 13.17 or newer — see Version policy for why that floor is higher than Polyslug's own. Polyslug detects the package and registers one macro.

One call

use Laravel\Head\Facades\Head;

public function show(Article $article)
{
Head::polyslug($article)
->title($article->title)
->description($article->excerpt);

return view('articles.show', compact('article'));
}

Head::polyslug() writes four things and nothing else. Title, description, Open Graph copy, cards and structured data stay yours.

It writesFromWhy laravel/head cannot do it alone
<link rel="canonical">the bound PolyslugUrlResolverits own default is the request URL, which can be the stale one
<link rel="alternate" hreflang>hreflangLinks()it has no access to the per-locale slugs
og:locale + one og:locale:alternate per other localethe same resolvermeta() keys tags by name, so it cannot repeat one — Polyslug registers a tag builder for the set
<meta name="robots"> for a gated modelpolyslugIsRoutable(), then polyslugRobotsDirective()it cannot see the visibility gate

It needs a bound PolyslugUrlResolver for the first three — the one class you write yourself, because the package cannot know your routes. Without one it writes none of them rather than guessing a URL shape, and it does so quietly: the page renders, the <head> is simply incomplete. The robots directive still applies, because that needs no resolver. php artisan polyslug:doctor reports whether the binding is there.

Choosing the robots directive

A model the gate keeps out of hreflang sets and sitemaps gets a robots meta tag, because it can still render — for its owner, a reviewer, whoever holds the preview link. Without the tag, one shared URL is enough to put a gated page into the index.

The shipped answer is none, and none means noindex, nofollow. That is the safe default, but it is a stronger statement than the gate makes: the gate is about indexability. It says this page must not appear in results; it says nothing about whether the links on that page can be trusted, and nofollow asserts they cannot. For a draft, a gated preview or a tenant-internal page, the wanted answer is usually noindex, follow — out of the index, while link equity still flows through the page.

Override polyslugRobotsDirective() on the model to choose:

use Polyslug\Concerns\HasPolyslug;

final class Article extends Model implements Sluggable
{
use HasPolyslug;

public function polyslugIsRoutable(?string $locale = null): bool
{
return $this->published_at !== null;
}

/**
* @return string|list<string>
*/
public function polyslugRobotsDirective(?string $locale = null): string|array
{
return ['noindex', 'follow'];
}
}

A string works too — 'noindex, follow' renders identically. Casing and surrounding spaces are normalized, so the tag cannot depend on which spelling you typed.

The $locale argument is the one the gate refused, so a model gated in some locales and not in others can answer per locale.

It must still prevent indexing

The directive is only ever consulted inside the gated branch, so it has to keep the page out of the index: it must contain noindex or none. Anything else — all, index, a bare follow — throws MisconfiguredPolyslug, because it would silently undo the gate that put you in this branch.

An empty answer is refused for a sharper reason. laravel/head renders no robots tag at all for an empty directive, and a page with no robots meta is indexable by default. So returning [] or '' would not be a permissive tag, it would be no tag — the worst of the three outcomes, and the hardest to notice.

Nothing changes if you say nothing

The method lives on the HasPolyslug trait, not on the Sluggable contract. A model that does not override it keeps none, and an application that implements Sluggable by hand, without the trait, has no such method at all — the bridge detects that and keeps none there too. No existing page changes its tag until somebody opts in.

Why the canonical URL is the point

Head::canonical() on its own falls back to the current request URL. In a Polyslug app that is usually right, because the polyslug.canonical middleware redirects a stale slug before the page renders. On a route without that middleware it is wrong, and wrong in the way that costs rankings:

requested (stale) /articles/hello-world_zvhdxktzmqln4e1q
Head::canonical() <link rel="canonical" href=".../articles/hello-world_zvhdxktzmqln4e1q">
Head::polyslug() <link rel="canonical" href=".../articles/renamed-title_zvhdxktzmqln4e1q">

The first tells a crawler the outdated URL is the authority. The second asks the resolver, which is the same resolver the sitemap uses — so the canonical URL, the hreflang set and the sitemap cannot disagree about which address a record has.

They can still differ in form, and it is worth knowing where: laravel/head normalizes every canonical it renders, including one it was handed — by default forcing https:// and stripping a trailing slash, either of which a host can flip through Head::defaults(). The hreflang set is emitted verbatim, and polyslug:sitemap never goes through laravel/head at all. A resolver that returns http:// therefore produces a canonical and a self-referencing hreflang that point at the same page by two spellings. A resolver built on route() or url() over HTTPS — what an application in production has — produces no divergence.

Pick one hreflang path, not both

Polyslug can render hreflang tags itself, and so can laravel/head. Using both puts two identical <link rel="alternate"> sets in one page.

If you…UseNot
use laravel/headHead::polyslug($model)@polyslugHreflang
do not use it@polyslugHreflang($model, $resolver)

Both build the set from the same hreflangLinks(), so the output is identical. The choice is about which layer owns the <head>, not about correctness.

Adding to a set is safe: alternates() merges, so a hand-written entry for a locale Polyslug does not know survives.

Head::alternates(['fr' => 'https://example.com/fr/legacy-page']);
Head::polyslug($article); // adds Polyslug's locales; keeps the French one only if $article has no fr slug

The merge is per key and Polyslug writes second, so a locale the model itself is routable in replaces the hand-written URL. That is usually what you want — the resolver knows the current address and the hand-written one is a leftover — but it is a replacement, not a merge, and nothing warns about it.

Locales

Head::polyslug() uses the active application locale by default. On a /{locale}/… route, pass it explicitly — the same rule that makes polyslugRouteKeyForLocale() exist:

Head::polyslug($article, $request->route('locale'));

Open Graph locales need a territory

Open Graph writes locales as language_TERRITORY. A locale that already has one passes through with its separator normalized: en-GB becomes en_GB, pt_BR stays as it is.

A locale without a territory gets no og:locale tag at all. A bare de is outside the format, and a scraper that cannot parse the value does not read a language from it — it falls back to its own default, usually en_US. The tag was never saying what it looked like it was saying, so omitting it costs nothing but the claim.

The territory is not the package's to invent — en is en_US to one site and en_GB to another — so name the pairs you want:

// config/polyslug.php
'open_graph' => [
'locale_map' => [
'en' => 'en_US',
'de' => 'de_DE',
],
],

Anything the map does not cover, and anything mapped to a value that is still not language_TERRITORY, is left out rather than guessed at.

If the model is not routable in the requested locale, neither the canonical URL nor the og:locale is written — only the alternates for the locales that are routable, plus the robots directive below. Naming the withheld locale's URL in a canonical tag would advertise the very address the same page is hiding.

The directive is checked against real vocabulary

A robots tag has no error channel: a crawler drops a token it does not recognize, renders the rest, and the page behaves as though the directive was never written. nofollw for nofollow therefore produces a tag that looks right and restricts nothing.

So an unrecognized directive raises MisconfiguredPolyslug instead of shipping. Both halves are checked — the name and, for the ones that carry a value, the value:

public function polyslugRobotsDirective(?string $locale = null): array
{
return ['noindex', 'max-image-preview:large']; // fine
return ['noindex', 'max-image-preview:huge']; // refused: no crawler accepts "huge"
}

Accepted: all, index, follow, noindex, nofollow, none, noarchive, nosnippet, indexifembedded, notranslate, noimageindex, nositelinkssearchbox, nocache, max-snippet:N, max-image-preview:none|standard|large, max-video-preview:N, unavailable_after:DATE.

A site-wide robots hint goes through Head::defaults()

laravel/head replaces a string tag rather than merging it: whichever call comes last wins outright. So this un-gates the page, silently —

Head::polyslug($page)->robots('max-image-preview:large'); // the gate's `none` is GONE

— and this does not:

Head::robots('max-image-preview:large')->polyslug($page); // `none` survives

Nothing goes red either way, and the rendered tag looks deliberate. For a hint you want on every page, use a default: it applies as the base, so Polyslug's value overlays it and a gated page stays hidden.

// AppServiceProvider::boot()
Head::defaults(fn ($head) => $head->robots('max-image-preview:large'));

Polyslug cannot win that race on its own — it is not the last writer and has no way to become one. Both orderings are measured in the package's suite rather than reasoned about, so this guidance rests on a run.

The gone page can carry its own metadata

polyslugIsGone() answers 410 by throwing a real HttpException, and laravel/head resolves an error status off anything implementing HttpExceptionInterface. So a per-status head applies to Polyslug's 410 with no wiring at all:

// AppServiceProvider::boot()
Head::errors(fn ($pages) => $pages->status(410, title: 'This page is gone'));

It looks like something that has to be built, and it is not. Both halves are held by the package's suite — the upstream resolver, and a real request through the canonical middleware arriving with that title on the head.

Gated models stay out of the index

This is the part worth reading twice.

polyslugIsRoutable() already keeps a model out of hreflang sets and sitemaps — a draft, another tenant's row, a page whose visibility was withdrawn. But such a model often still renders, for its owner or a reviewer with a preview link. Nothing in that page tells a crawler to stay away, so one shared URL is enough to index it.

class Article extends Model implements Sluggable
{
use HasPolyslug;

public function polyslugIsRoutable(?string $locale = null): bool
{
return $this->published_at !== null;
}
}

With the integration in place, an unpublished article renders <meta name="robots" content="none"> automatically. No extra call, and no way to forget it on one of the routes.

Routes compose

The Route::polyslug() macro returns an ordinary route, so laravel/head's withHead() chains straight onto it:

Route::polyslug('/articles/{article}', [ArticleController::class, 'show'])
->name('articles.show')
->withHead(description: 'Long-form writing.');

Route-level metadata sits below runtime metadata in laravel/head's resolution order, so a later Head::polyslug() refines it rather than fighting it.

Version policy

laravel/head is a suggest, never a requirement, and Polyslug's own dependency set is unchanged by this page. The package is on a 0.x line, so its API may still move; the integration deliberately couples to a small number of its methods, and a test suite pins each of them at its rendered output so a breaking release surfaces as a failing build rather than as quietly wrong tags.

The floor is laravel/head's, not Polyslug's, and it is higher. Every published laravel/head release requires illuminate/* ^13.17.0 — the version that introduced the route metadata API its withHead() macros build on. Polyslug requires only ^13.0, and that stays deliberate: an application pinned below 13.17 runs Polyslug in full and simply cannot add this one optional integration. Composer says so plainly rather than failing at runtime — the install is refused, not silently degraded.