cd /news/developer-tools/migrating-the-seo-on-page-from-custo… · home topics developer-tools article
[ARTICLE · art-109479] src=danielpetrica.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Migrating the SEO on page from custom codeto Laravel/head for Laraplugins

Daniel Petrica migrated 104,000 pages on LaraPlugins.io from a custom Blade SEO component to the new Laravel/Head package, replacing an 80-line component and scattered @push('head') calls with a single source of truth for metadata. The migration, completed at the start of August, covers titles, descriptions, canonical URLs, Open Graph tags, Twitter cards, JSON-LD structured data, robots directives, and resource hints across the directory's 82,000 Laravel packages, 40,000 vendors, and 56,000 maintainers, serving 3 million monthly requests. Petrica verified Google did not notice the change, ensuring SEO rankings remained unaffected.

read10 min views1 publishedAug 25, 2026
Migrating the SEO on page from custom codeto Laravel/head for Laraplugins
Image: source

Laravel

For eight months, every <head> tag on my 104k page Laravel directory ran through a homegrown Blade component. At the start of August I replaced all of it with the new laravel/head package. Here's the full migration, and how I verified Google never noticed

You may be bored already of hearing me speak about Laraplugins but this is my biggest project so far and I am spending all my "free" so here is another story about it, somehow.

So I want you to visualize this for a minute:

LaraPlugins.io indexes 82k Laravel packages, 40k vendors, 56k maintainer so in total every month it serves 3 million requests a month across:

  • Traditional Web
  • MCP traffic
  • Markdown views
  • and a Laravel/Doctor integration info here

As this is a directory at its core I need to have correct SEO metadata for every page i submit to google, 104k of them at least. I do not add to the sitemap all of the pages right now.

So for every page that means titles, descriptions, canonical URLs, Open Graph tags, Twitter cards, JSON-LD structured data, robots directives, resource hints. I believe every <meta>

and <link>

tag in the <head>

matters a little.

For the past 8 months, I handled this with a homegrown system: a custom <x-seo-metadata>

Blade component, View composers injecting defaults, manual <title>

tags scattered across layouts, and a growing collection of @push('head')

calls. It worked. Mostly. But it had the kind of rough edges you only notice when a page with 10,000 monthly visitors ships without an Open Graph image because someone forgot to pass ogImage

to the component (hypothetical situation as so far no page reached that traffic for me yet).

At the end of July when the laravel team released the new Laravel/Head package i saw an opportunity to optimize and reduce my app custom code, so I decided to implement the package inside Laraplugins.io.

The Starting Point #

Before the migration, a typical page's <head>

was assembled from multiple sources:

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>@yield('title', 'LaraPlugins.io')</title>

    <x-seo-metadata
        :title="$seoTitle ?? 'LaraPlugins.io'"
        :description="$seoDescription ?? null"
        :canonical="$canonicalUrl ?? null"
        :ogImage="$ogImage ?? null"
        :ogType="$ogType ?? 'website'"
    />

    @stack('head')

    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>

The <x-seo-metadata>

component was about 80 lines of Blade. It rendered Open Graph tags, Twitter cards, canonical links, and robots directives conditionally. If you didn't pass ogImage

, it silently omitted the og:image

tag. If you forgot canonical

, it used the current URL. These defaults hid mistakes, and sometimes created them.

The @stack('head')

calls scattered across controllers and models were the real problem. When structured data for a plugin's SoftwareApplication schema lives in a @push('head')

inside a model accessor, it's easy to lose track of what ends up on the page.

This mess was my fault of course but back in December when I started working on laraplugins i did not immagine the projects will reach these numbers so fast. as said previously i was not prepared for the site growth and

I wanted a single source of truth for <head>

metadata. laravel/head

provides exactly that.

Layer 1: Global Defaults #

Every page on LaraPlugins.io inherits from Head::defaults()

in AppServiceProvider

:

Head::defaults(fn (HeadBuilder $head) => $head
    ->title('LaraPlugins.io')
    ->description('Discover the best Laravel packages. Health scores, security advisories, and version history for 82,000+ Composer packages. Available via web, MCP, and markdown.')
    ->canonical(forceHttps: true)
    ->robots('index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1')
    ->og(siteName: 'LaraPlugins.io', type: OgType::Website, locale: 'en_US')
    ->twitter(card: TwitterCard::SummaryWithLargeImage)
    ->meta('twitter:site', '@LaraPlugins')
    ->meta('twitter:creator', '@danielpetrica')
    ->meta('author', 'Daniel Petrica')
    ->meta('publisher', 'Daniel Petrica')
    ->meta('pinterest', 'nopin')
    ->meta('article:author', 'https://danielpetrica.com')
    ->meta('article:section', 'Technology')
    ->meta('article:tag', 'Laravel,PHP,Plugin,Directory')
    ->link('sitemap', 'https://laraplugins.io/sitemap.xml', ['type' => 'application/xml'])
    ->preconnect('https://hello.danielpetrica.com')
    ->preconnect('https://umami-unr.danielpetrica.com')
);

This is the fallback layer. If a controller doesn't explicitly set a title, the page gets "LaraPlugins.io." If no Open Graph image is specified, it doesn't inherit a stale one — it simply omits the tag. The canonical(forceHttps: true)

call ensures every page's canonical URL uses HTTPS regardless of how the request arrived.

The resource hints (preconnect

, dnsPrefetch

) are particularly nice. Previously these were hardcoded in the layout. Now they live in one place with the rest of the <head>

configuration.

Layer 2: The @head Directive #

The layout change is almost too simple to write about:

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    @head
    @stack('head')
    @vite(...)
</head>

The @head

directive renders everything the package has accumulated: defaults merged with any route-level metadata, merged with any controller overrides. One directive. No View composers. A single @stack('head')

remains only for the few complex model schemas not yet migrated (see “What Stayed Custom”); everything else is gone.

If you forget @head

in your layout, the page ships with no <title>

, no meta description, no canonical URL, no Open Graph tags, and no structured data. while it may sounds scary, and it is, it can also be good too, a missing @head

is a hard failure you'll catch immediately. You will have less change of a silent omission of an og:image

you'll discover in the Slack preview two days later.

Layer 3: Route-Level Metadata #

Static marketing pages don't need a controller to declare their SEO metadata. laravel/head

provides withHead()

directly on routes:

Route::get('/mcp', [MarketingController::class, 'mcp'])
    ->name('marketing.mcp')
    ->withHead(
        title: 'LaraPlugins MCP — AI-native Plugin Discovery',
        description: 'Reduce LLM hallucinations by letting your AI coding agent search 82,000 Laravel packages through the LaraPlugins MCP server.',
        ogImage: '/og/feature/mcp',
    );

This is the package's killer feature for sites with many static pages. No controller. No View composer. No @section('title')

in the Blade template. The route declares its own metadata, and @head

renders it.

Layer 4: Controller-Level Metadata #

Eleven controllers handle the dynamic pages on LaraPlugins.io. Each one uses the fluent API to override defaults per request:

// Plugin detail page — PluginListingController
Head::title($plugin->getSeoTitleAttribute())
    ->description($plugin->getSeoDescriptionAttribute())
    ->ogImage(route('og.plugin', [
        'vendor' => $plugin->vendor->name,
        'package' => $plugin->clean_name,
    ]))
    ->schema($this->pluginBreadcrumbs($plugin))
    ->schema($this->pluginFaqSchema($plugin));

// Blog post — BlogController
Head::title(BlogBusiness::seoTitle($post) . ' — LaraPlugins Blog')
    ->description(BlogBusiness::seoDescription($post))
    ->canonical($post->canonical_url ?? route('blog.show', $post->slug))
    ->og(type: OgType::Article, image: BlogBusiness::ogImage($post))
    ->link('alternate', route('blog.rss'), ['type' => 'application/rss+xml'])
    ->schema(Schema::breadcrumbs()->items([...]));

The controller is the single place where metadata decisions happen. The fluent chain reads top to bottom: title, description, OG image, breadcrumbs schema, FAQ schema. No View composers distributing logic. No @push('head')

in Blade templates receiving data from three different sources.

Layer 5: Schema.org JSON-LD #

This is where the package genuinely shines. Head::schema()

accepts schema objects and renders them as JSON-LD. I built a thin Schema::

abstraction layer on top:

// Breadcrumbs — used on 7 page types
Head::schema(Schema::breadcrumbs()->items([
    ['name' => 'Home', 'url' => route('home')],
    ['name' => 'Plugins', 'url' => route('plugins')],
    ['name' => $plugin->vendor->name, 'url' => route('vendor', $plugin->vendor->slug)],
    ['name' => $plugin->clean_name, 'url' => route('plugin', [...])],
]));

// FAQ — 9 dynamic questions per plugin
Head::schema(Schema::faq()->questions([
    ['question' => "What is {$plugin->clean_name}?", 'answer' => $plugin->description],
    // ... 8 more dynamic questions
]));

// WebSite + Organization — homepage
Head::schema(Schema::webSite()->name('LaraPlugins.io')->url('https://laraplugins.io')->potentialAction([...]));
Head::schema(Schema::organization()->name('LaraPlugins.io')->url('https://laraplugins.io')->sameAs([...]));

Builder | Where It's Used | |---|---| | Schema::breadcrumbs() | Plugin detail, blog, security advisory, vendor, maintainer, plugin list | | Schema::faq() | Plugin detail (9 dynamic Qs), blog posts with FAQ data, homepage | | Schema::webSite() | Homepage | | Schema::organization() | Homepage |

Layer 6: Error Pages #

Head::errors(fn (ErrorPages $errors) => $errors
    ->defaults(robots: 'noindex, follow')
    ->status(
      404,
      title: 'Page Not Found',
      description: 'The page you are looking for does not exist.'
    )
    ->status(
      500, 
      title: 'Server Error', 
      description: 'Something went wrong on our end.'
    )
    ->status(
      503, 
      title: 'Service Unavailable', 
      description: 'LaraPlugins is temporarily unavailable.'
    )
);

The defaults(robots: 'noindex, follow')

call ensures every error page is excluded from search indexing . With a single line that the package replace a manual <meta name="robots">

tag in the error layout which honestly i always forget to add. .

What Stayed Custom #

Not everything moved into the package. Here's what I kept custom, and why:

OG Image Generation: 8 SVG templates rendered by anOpenGraphController

. The package doesn't generate images; it receives URLs viaHead::ogImage()

.Sitemaps: OurSitemapController

assigns health-score-based priorities. The package doesn't model sitemap logic.RSS Feeds: Four feeds generated independently. The package handles feed discovery viaHead::link('alternate', ...)

.Model SEO Accessors:getSeoTitleAttribute()

,getSeoDescriptionAttribute()

,toSchemaArray()

on Plugin, Vendor, and Contributor models.HTML Microdata:itemprop

/itemscope

on breadcrumbs. The package focuses on JSON-LD.Complex Model Schemas: SoftwareApplication, BlogPosting still via@push('head')

. Future migration candidate.

Migration Verification #

Migrating 82,000 pages worth of SEO metadata needs verification beyond "looks good on my machine." especially when i have so little trust in big migration. Here's how I confirmed nothing broke:

Rendered output diffs. Crawled the top 500 pages by traffic before and after, diffing rendered<head>

output.Schema validation. Every JSON-LD page run through Google's Rich Results Test.HTTP tests. Key page types now assert metadata:

test('plugin detail page has correct head metadata', function () {
    $plugin = Plugin::factory()->create();
    $this->get(route('plugin', [
        'vendor' => $plugin->vendor->name,
        'package' => $plugin->clean_name,
    ]))
        ->assertSee('<title>' . $plugin->getSeoTitleAttribute() . '</title>', false)
        ->assertSee('<meta name="description"', false)
        ->assertSee('<meta property="og:title"', false)
        ->assertSee('<script type="application/ld+json">', false);
});

Search Console monitoring. 72 hours post-deploy. No errors, no drop in valid pages.

What I'd Do Differently #

Start with the test suite. Write HTTP assertions for<head>

metadata before touching production code.Build the Schema:: abstraction first. Defining the builders up front keeps the controller migration cleaner.Don't migrate everything at once. Complex schemas still use@push('head')

.@head

and@push('head')

coexist without issues.

The Real Challenge: Ongoing SEO at Scale #

Ok so now that i migrated the on page seo construction how can i ensure it stays correct and no page is missing one, including new ones? This is an impossibile task to handle manually.

LaraPlugins.io has over 200,000 pages when you count every plugin detail view, vendor page, blog post, security advisory, and the various output formats (web, MCP, markdown). One page shipping without an og:image

or with a broken canonical URL is a needle in a 200,000-page haystack. Google will eventually notices. I will notice only after google probably.

So manual audits or monitoring of the technical seo is impossible, due to time and economic reasons, for this reason I decided to build a tool to handle this.

It crawls every page starting from the sitemap similarly to the way Google does.

It checks titles, canonicals, Open Graph tags, and structured data, and flags the broken ones before Google notices instead of after.

It's called SEO by Daniel (super original i know), and while is open already and invite you to try it, i will describe it more soon.

The migration solved how metadata gets built. This is how I make sure it stays correct.

If you want to check your site or homepage really quick use this button to do so

Do a free audit of your site

Should You Use It? #

If you ship public-facing Laravel pages, yes. Even at v0.2.1, laravel/head

replaces a surprising amount of boilerplate:

  • No more custom Blade components for <head>

metadata - No more View composers distributing SEO defaults

  • No more @stack('head')

scattered across layouts, controllers, and models - No more remembering to add Twitter cards to that one marketing page

  • Schema.org JSON-LD through a single Head::schema()

call

The package doesn't generate OG images, build sitemaps, produce RSS feeds, or manage model-level SEO accessors. It doesn't need to. It handles the <head>

tag — all of it — through one fluent API. Everything else lives where it belongs: in dedicated controllers, models, and services.

For a site with 104,000 indexed pages and 3 million monthly page-views, the migration was worth a weekend. For a smaller site, it's worth an afternoon.

This article was written against laravel/headv0.2.1 (current as of August 2026). Note: it requires PHP 8.3+ and Laravel 13. I'll update it as the API evolves.

── more in #developer-tools 4 stories · sorted by recency
── more on @laravel 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/migrating-the-seo-on…] indexed:0 read:10min 2026-08-25 ·