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/head | How does a tag reach the <head>? |
| Polyslug | What 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 writes | From | Why laravel/head cannot do it alone |
|---|---|---|
<link rel="canonical"> | the bound PolyslugUrlResolver | its 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 locale | the same resolver | meta() keys tags by name, so it cannot repeat one — Polyslug registers a tag builder for the set |
<meta name="robots"> for a gated model | polyslugIsRoutable(), 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… | Use | Not |
|---|---|---|
use laravel/head | Head::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.