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 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 list may also hold laravel/head's own RobotsRule cases, or any string-backed enum: [RobotsRule::NoIndex, RobotsRule::Follow] renders the same tag.

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 and its case normalized, the language in lower case and the territory in upper: en-GB becomes en_GB, a pt-br from a path becomes pt_BR, and 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 valued directive may carry a space after the colon, the way Google's own examples write it: unavailable_after: 2020-09-21 is accepted and rendered as unavailable_after:2020-09-21. Directives are matched case-insensitively and rendered in lower case, the value included.

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 cannot add this one optional integration. Composer says so plainly rather than failing at runtime — the install is refused, not silently degraded.