laravel/head
Laravel Head offers a fluent API for managing your app's document head—titles, meta tags, Open Graph, canonical URLs, robots directives, performance hints, and structured data. Works with Blade, Livewire, and Inertia.
Install and confirm the render point first.
composer require laravel/head
The package is official but young (small install base, zero dependents), so treat the official docs as canonical and confirm exact method signatures before you build conventions on them. Skim CHANGELOG.md and the releases page before writing anything — on a package this new, minor bumps are where renames land.
The first thing to get right is not the API, it's the single render point. Head management only works if exactly one place in your base layout emits the collected tags:
{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{-- whatever the package's render call is: Head::render(), <x-head />, @head --}}
{{ Head::render() }}
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
Then delete your hardcoded <title> and per-page <meta> blocks. If both the layout and a page emit a title you get two <title> tags and browsers/crawlers pick arbitrarily.
First use case: replace the {{-- <title>{{ $title ?? config('app.name') }}</title> --}} pattern in one layout, set a site-wide default at the layout level, and override per page from a controller:
public function show(Post $post)
{
return view('posts.show', compact('post'))
->with('head', fn () => Head::title($post->title)
->description(Str::limit($post->excerpt, 155))
->canonical(route('posts.show', $post)));
}
Prove it end-to-end with curl -s https://your-app.test/posts/1 | grep -i '<title\|canonical' before rolling it across the app.
Layered defaults, page-level overrides. Establish site-wide head (app name suffix, default OG image, Twitter card type, favicons, preconnects) once — in a base layout, a view composer, or a HeadServiceProvider — and let pages override individual keys. Decide early whether your version of the package merges or replaces on repeated calls: that single behaviour determines whether a page has to re-specify the OG image every time. Write one test that asserts it and keep it green across upgrades.
Presenter class per resource type, not inline chains. Head logic in controllers rots fast. Put it in a small class that knows how to describe a model:
final class PostHead
{
public static function for(Post $post): Head
{
return Head::title($post->title)
->description(Str::limit(strip_tags($post->excerpt), 155))
->canonical(route('posts.show', $post))
->image($post->ogImageUrl() ?? asset('img/og-default.png'))
->type('article')
->publishedAt($post->published_at)
->jsonLd(ArticleSchema::for($post));
}
}
Call it from the controller, a Blade component, an Inertia response, or a Livewire mount() — same code, one place to fix.
Structured data: use the built-in schemas where they fit. As of v0.2.2 the package ships an event schema helper (PR #13), alongside the article-style schemas. For events, conferences, webinars, and anything with a date/venue, prefer the first-party helper over hand-rolled JSON-LD — it keeps the shape consistent with the rest of the head chain and survives future fixes. Reserve manual JSON-LD for bespoke types (Product, FAQ, BreadcrumbList) that the package doesn't model yet.
public static function for(Event $event): Head
{
return Head::title($event->name)
->description(Str::limit($event->summary, 155))
->canonical(route('events.show', $event))
->eventSchema($event); // v0.2.2+
}
Because a new schema type just landed, re-read the schema docs before hand-building one — the surface is still growing, and the built-in version will be tested against Google's Rich Results expectations where your hand-rolled array may not be.
Reusable Blade components for shared shapes. <x-seo.post :post="$post" /> keeps views tidy and gives you a single override surface when marketing wants to tweak card copy.
Canonical URLs need policy, not a default. Blindly canonicalising to the current URL means ?utm_source=, ?page=2, and filtered views all self-canonicalise and compete with each other. Canonicalise paginated/filtered pages to the clean base URL, and deliberately leave truly distinct filtered pages non-canonical.
Environment-aware directives. noindex for staging and for auth/checkout/thank-you routes belongs in a middleware or layout conditional, not copy-pasted. One if (! app()->isProduction()) beats fifty forgotten pages.
Inertia. Set head data server-side in the Inertia response for correct first paint and crawler-visible tags, and use the client-side head component for anything that changes on SPA navigation (route params, live title updates). Verify by navigating client-side and viewing source vs. inspecting — they diverge, and only one of them is what a crawler sees on first load.
Livewire. wire:navigate swaps the document without a full reload; confirm the package re-applies head data on navigation rather than leaving the previous page's title. Test with a direct visit and a navigation from another page.
Performance hints (preload, preconnect, dns-prefetch) are easy wins for fonts and CDN origins — but scope them per layout, not globally, or every page pays for assets only one page uses.
Test the rendered HTML, not the builder. HTTP tests asserting assertSee('<link rel="canonical"', false) and assertSee($post->title) catch duplicates and escaping bugs that unit-testing the fluent chain never will. Add a cheap assertion for substr_count($html, '<title') === 1.
title() call wins, is ignored, or appends decides your whole inheritance strategy. One test on day one saves a debugging session on day ninety.</script> sequences in user-controlled strings can't break out — validate any JSON-LD that contains a model field a user can edit. This applies to the v0.2.2 event schema exactly as it does to your hand-rolled arrays: event names, locations, and descriptions are often user-submitted.curl, "View Page Source", or Http::get() in tinker shows what crawlers and social scrapers actually receive. Duplicate tags are obvious in one and invisible in the other.PostHead::for() should come from already-eager-loaded relations; this runs on every render. Schema builders that accept a model are especially prone to lazy-loading relations.composer.json, read the changelog on every bump, and route all head construction through a couple of presenter classes so a renamed method is a one-file change rather than a codebase-wide find-and-replace. v0.2.2 is additive, but it's a reminder that the schema API is still moving — assert on the rendered JSON-LD, not on a method name returning truthy.->hreflang() or ->analytics() helpers), whether custom tags/raw snippets can be pushed, and whether a custom renderer or Blade directive exists. Custom macros are the cheapest way to encode house conventions without forking.How can I help you explore Laravel packages today?