Originally published at hafiz.dev
Every production Laravel app solved the <head> problem years ago, one of three ways: artesaos/seotools with its family of facades, ralphjsmit/laravel-seo with SEO models in the database, or a hand-rolled Blade partial stuffed with <meta> lines that grew one tag at a time since 2021. This blog runs the third kind.
Then Taylor showed Laravel Head on stage during the day-one Laracon US keynote, and now there's a first-party answer: composer require laravel/head, a fluent API for titles, descriptions, canonicals, Open Graph, X cards, robots directives, JSON-LD, resource hints, and favicons, resolved per request across Blade, Livewire, and Inertia.
Laravel News already covered how to use it, and the official docs are thorough. The question nobody has answered is the one existing apps actually have: I have SEOTools calls in thirty controllers, or SEO rows in my database, or a 120-line partial. Is switching worth it, and what does the mapping look like? That answer is different for each of the three groups, so let's take them one at a time.
First, the gate everyone hits before the interesting questions: Laravel Head requires PHP 8.3 and Laravel 13.17 or later. Below that, this whole post is academic until you upgrade. It needs 13.17 specifically because withHead() is built on the native route metadata API that shipped in that release, which I covered in the route metadata breakdown when it landed.
What Head actually is, in ninety seconds
Metadata resolves through five layers, lowest to highest priority: page defaults, route group metadata, route metadata, runtime metadata, and error metadata. Higher layers replace lower ones one field at a time, so a runtime title overrides the route's title without touching the route's description.
Site-wide defaults live in a service provider:
use Laravel\Head\Enums\OgType;
use Laravel\Head\Enums\TwitterCard;
use Laravel\Head\Facades\Head;
use Laravel\Head\HeadBuilder;
Head::defaults(function (HeadBuilder $head) {
$head
->title('hafiz.dev', suffix: ' - hafiz.dev')
->description('Laravel, shipped honestly.')
->canonical()
->og(siteName: 'hafiz.dev', type: OgType::Website)
->twitter(card: TwitterCard::SummaryWithLargeImage)
->searchableByRobots();
});
Static pages attach metadata to the route itself, and it survives route caching because withHead() writes plain arrays through the route metadata API:
Route::view('/contact', 'contact')
->name('contact')
->withHead(
title: 'Contact',
description: 'Get in touch.',
);
Dynamic pages set the rest at runtime:
public function show(Post $post)
{
Head::title($post->title)
->description($post->excerpt)
->ogImage($post->featuredImageUrl())
->when($post->isDraft(), fn ($head) => $head->hiddenFromRobots());
return view('posts.show', ['post' => $post]);
}
And the layout renders everything with one directive:
<head>
<meta charset="utf-8">
@head
</head>
That's the whole model. Now the migration questions.
Group one: artesaos/seotools apps
SEOTools is the incumbent at 5.2 million installs, and if you've maintained a Laravel app for more than a few years, odds are decent you're in this group. The migration is the most mechanical of the three, and it's also the one with the clearest payoff, because of something I'll call the duplication tax.
Here's a representative SEOTools controller method, straight out of the pattern its own README teaches:
use Artesaos\SEOTools\Facades\SEOMeta;
use Artesaos\SEOTools\Facades\OpenGraph;
use Artesaos\SEOTools\Facades\TwitterCard;
use Artesaos\SEOTools\Facades\JsonLd;
public function show(Post $post)
{
SEOMeta::setTitle($post->title);
SEOMeta::setDescription($post->excerpt);
SEOMeta::setCanonical(route('posts.show', $post));
OpenGraph::setTitle($post->title);
OpenGraph::setDescription($post->excerpt);
OpenGraph::setUrl(route('posts.show', $post));
OpenGraph::addImage($post->featuredImageUrl());
OpenGraph::addProperty('type', 'article');
TwitterCard::setTitle($post->title);
TwitterCard::setImage($post->featuredImageUrl());
JsonLd::setTitle($post->title);
JsonLd::setDescription($post->excerpt);
JsonLd::addImage($post->featuredImageUrl());
return view('posts.show', ['post' => $post]);
}
Four facades, and the title appears four times. That's the duplication tax: SEOTools treats meta, Open Graph, Twitter, and JSON-LD as four independent surfaces, so every value you care about gets set on each of them, and every future edit happens in four places. Miss one and your og:title silently drifts from your <title>, which is exactly the class of bug nobody notices until a shared link renders wrong.
The same method under Head:
use Laravel\Head\Facades\Head;
use Laravel\Head\Facades\Schema;
public function show(Post $post)
{
Head::title($post->title)
->description($post->excerpt)
->ogImage($post->featuredImageUrl())
->og(type: OgType::Article)
->schema(
Schema::blogPosting()
->name($post->title)
->description($post->excerpt)
);
return view('posts.show', ['post' => $post]);
}
The document title and description fill in og:title, og:description, and the Twitter card values automatically, and the canonical comes from canonical() in your defaults, which uses the current request URL and normalizes it to https without being told. Fifteen lines became six, and the four-surface synchronization problem is gone because there's one source of truth.
The layout side shrinks the same way. SEOTools renders through four separate calls:
{!! SEOMeta::generate() !!}
{!! OpenGraph::generate() !!}
{!! Twitter::generate() !!}
{!! JsonLd::generate() !!}
All of that becomes @head.
There's also a maintenance argument that's uncomfortable to say out loud but belongs in the decision. SEOTools has been community-maintained since 2015, sits at v1.4.1, and its Packagist page currently lists three security advisories in its history. It works, and the maintainers deserve credit for a decade of service. But a first-party package that just got keynote billing is going to out-develop it from here, and the direction of that gap only points one way.
Verdict for this group: migrate, and don't drag it out. The mapping is mechanical, each page gets shorter, and running both packages during a transition is safe as long as only one of them renders a given tag. Move page by page, and delete the SEOTools generate calls from the layout last.
Group two: ralphjsmit/laravel-seo apps
This one is a different animal, and anyone telling you it's the same migration hasn't read what the package does. ralphjsmit/laravel-seo stores SEO as Eloquent models: a HasSEO trait on your Post creates and associates an SEO row in the database, editable through whatever admin you've built, retrievable through a seo relationship.
Laravel Head has no persistence layer at all. It resolves metadata from code, per request, and stores nothing. That's a deliberate design choice, not a gap they forgot, but it means the thing ralphjsmit users actually bought (SEO as content, editable without a deploy) doesn't come in the box.
The honest migration for this group keeps the database and swaps the rendering. Your SEO columns or the existing SEO model rows stay exactly where they are, and Head becomes the output layer:
public function show(Post $post)
{
Head::title($post->seo->title ?? $post->title)
->description($post->seo->description ?? $post->excerpt)
->ogImage($post->seo->image ?? $post->featuredImageUrl());
return view('posts.show', ['post' => $post]);
}
That works, and it's less code than it looks once you extract it into a small HasHeadMetadata trait of your own. But notice what happened: you rebuilt the package's model integration yourself, thinly. Whether that trade is worth it depends on how much you use the rest of what Head brings, especially the route-level metadata and the Inertia story below.
Verdict for this group: no urgency. Your package is actively maintained (1.8.1 shipped in March, Laravel 13 supported), and the DB-backed workflow is a real feature Head doesn't replace. Migrate if you want the first-party trajectory and are willing to own a thin bridge trait, or when your admin-editable SEO turns out to be three fields nobody edits, which is more common than anyone admits.
Group three: the hand-rolled partial
This is the group I'm in. hafiz.dev renders its meta tags from a Blade partial fed by the Post model: title, description, canonical, OG image with dimensions, article timestamps, Twitter card. It predates both packages' current versions, it works, and like every hand-rolled meta partial it has accumulated conditionals nobody remembers the reason for.
For this group the migration case isn't about deleting a dependency, since there's no dependency to delete. It's about three things the partial approach can't do well:
Static pages stop needing controller ceremony. Contact, about, pricing, legal, the whole marketing shell around a SaaS panel: every hand-rolled setup either hardcodes their meta in per-page views or threads variables through view composers. withHead() on the route definition replaces both, and it's cacheable.
The admin noindex problem gets one line. Every app has route groups that should never be indexed. With a partial, that's a conditional inside the partial checking the route name, which is exactly the kind of accumulated conditional I just complained about. With Head it's Route::withHead(robots: 'noindex, nofollow') on the group, next to the routes it describes.
Error pages get real metadata. Hand-rolled partials almost never handle 404 and 500 pages properly because the layout context is different there. Head lets you register per-status title, description, and robots values once in a service provider, and that's a category of page most of us silently ship with default or broken meta.
The migration itself is pleasant for this group because you're not translating an API, you're replacing string soup with structure. My plan for this blog is exactly the page-by-page approach: defaults into a provider first, then the blog post route, diffing the rendered <head> against the current partial's output before and after. The Head::toArray() method is quietly useful here, since it gives you the resolved metadata as a structured array you can assert against in a test instead of regex-matching HTML.
Verdict for this group: migrate at your own pace, newest pages first. Nothing is broken today, but every new page you add to the partial is a page you'll migrate later.
What Head doesn't do, so you don't uninstall the wrong thing
Three jobs commonly live next to meta tags, and none of them moved:
-
Sitemaps. Head has
paginate(),alternates(), andfeed()links, but it doesn't generate sitemap.xml. spatie/laravel-sitemap keeps its job. -
OG image generation. Head references image URLs, it doesn't create the images. If you generate OG images dynamically, the setup from my spatie/laravel-og-image walkthrough still owns that step, and its output URL is what you hand to
ogImage(). - Stored, admin-editable SEO content. As covered above: no database, no admin UI, by design.
If your mental model is "Head replaces my SEO stack", adjust it to "Head replaces the rendering layer of my SEO stack". Everything that produces the values still exists.
The Inertia part, which is quietly the biggest upgrade
If you run Inertia, this is the section that should decide you, because the old world was bad: metadata managed by a client-side <Head> component means crawlers and link-preview bots that don't execute JavaScript see whatever your SSR setup happens to emit, which for many apps is nothing.
Head shares the resolved metadata as rendered element strings on a head prop, and with Inertia's serverHead option (Inertia 3.5+) those tags land in the initial HTML response. Each element carries a stable data-inertia key that Inertia adopts and keeps synchronized across visits and back/forward navigation. Preview bots read real tags without running a line of JavaScript, and you delete your client-side <Head> usage entirely, which the docs are explicit about: don't let both systems manage the same element.
Session-static tags (viewport, favicons, manifest) register once through Head::inertiaGlobals() and stay out of the per-page prop. It's a carefully thought-through design, and it solves a problem the community packages never fully cracked because it needed cooperation from Inertia itself. First-party coordination is the whole pitch, and this is what it looks like in practice.
My take
For new apps this isn't a decision, it's the default: Head from day one, spatie/laravel-sitemap next to it, done.
For existing apps, the three verdicts above compress to one sentence each. SEOTools apps should migrate because they're paying the duplication tax on every page and holding a package whose maintenance trajectory now points the wrong way. ralphjsmit apps should wait until the DB-backed workflow stops earning its keep, then bridge. Hand-rolled apps should migrate opportunistically, newest pages first, because the partial only grows.
And one caution against the enthusiasm direction: don't migrate the week before something matters. Meta tags are the definition of quiet infrastructure, and the failure mode of a botched migration is invisible until search traffic dips or a shared link renders blank. Diff the rendered <head> per page type before and after, and keep the old system rendering until the diff is clean. This is a change you make boring on purpose.
FAQ
What are Laravel Head's minimum requirements?
PHP 8.3 and Laravel 13.17 or later. The 13.17 floor exists because route-level metadata through withHead() is built on the native route metadata API introduced in that release.
Does Laravel Head replace artesaos/seotools completely?
For rendering meta tags, Open Graph, Twitter cards, and JSON-LD, yes, and with less code because titles and descriptions propagate to the social tags automatically. It doesn't generate sitemaps, which SEOTools never did either.
Can I run Laravel Head and my current SEO package at the same time?
Yes, during a migration. The rule is that only one system may render a given tag on a given page, so move page by page and remove the old package's render calls from the layout only when every page has switched.
Does Laravel Head work with route caching?
Yes. withHead() stores plain arrays through Laravel's native route metadata API, which is fully compatible with route:cache.
Does Laravel Head store SEO data in the database?
No. It resolves everything from code at request time. If you need admin-editable SEO content, keep your existing storage (columns or a package like ralphjsmit/laravel-seo's models) and feed those values into Head at runtime.
Wrapping up
The interesting thing about Laravel Head isn't any single feature, it's that the <head> element finally has a first-party owner, which means the Blade, Livewire, and Inertia rendering paths all got solved by one team with access to all three codebases. That's the thing no community package could ever fully deliver, and it's why the Inertia integration is the best part of the release.
Figure out which of the three groups you're in, apply that group's verdict, and whichever it is, diff your rendered head before and after. Quiet infrastructure deserves boring migrations.
Top comments (0)