{"slug": "migrating-the-seo-on-page-from-custom-codeto-laravel-head-for-laraplugins", "title": "Migrating the SEO on page from custom codeto Laravel/head for Laraplugins", "summary": "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.", "body_md": "[Laravel](https://danielpetrica.com/tag/laravel/)\n\n# I migrated 100k pages to the new Laravel Head package for seo\n\nFor 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\n\nYou 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.\n\nSo I want you to visualize this for a minute:\n\nLaraPlugins.io indexes 82k Laravel packages, 40k vendors, 56k maintainer so in total every month it serves 3 million requests a month across:\n\n- Traditional Web\n- MCP traffic\n- Markdown views\n- and a Laravel/Doctor integration\n[info here](https://laraplugins.io/doctor-health?ref=danielpetrica.com)\n\nAs 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.\n\nSo 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>`\n\nand `<link>`\n\ntag in the `<head>`\n\nmatters a little.\n\nFor the past 8 months, I handled this with a homegrown system: a custom `<x-seo-metadata>`\n\nBlade component, View composers injecting defaults, manual `<title>`\n\ntags scattered across layouts, and a growing collection of `@push('head')`\n\ncalls. 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`\n\nto the component (hypothetical situation as so far no page reached that traffic for me yet).\n\nAt the end of July when the laravel team released the new [Laravel/Head](https://github.com/laravel/head?ref=danielpetrica.com) package i saw an opportunity to optimize and reduce my app custom code, so I decided to implement the package inside [Laraplugins.io](https://laraplugins.io/?ref=danielpetrica.com).\n\n## The Starting Point\n\nBefore the migration, a typical page's `<head>`\n\nwas assembled from multiple sources:\n\n```\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>@yield('title', 'LaraPlugins.io')</title>\n\n    <x-seo-metadata\n        :title=\"$seoTitle ?? 'LaraPlugins.io'\"\n        :description=\"$seoDescription ?? null\"\n        :canonical=\"$canonicalUrl ?? null\"\n        :ogImage=\"$ogImage ?? null\"\n        :ogType=\"$ogType ?? 'website'\"\n    />\n\n    @stack('head')\n\n    @vite(['resources/css/app.css', 'resources/js/app.js'])\n</head>\n```\n\nThe `<x-seo-metadata>`\n\ncomponent 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`\n\n, it silently omitted the `og:image`\n\ntag. If you forgot `canonical`\n\n, it used the current URL. These defaults hid mistakes, and sometimes created them.\n\nThe `@stack('head')`\n\ncalls scattered across controllers and models were the real problem. When structured data for a plugin's SoftwareApplication schema lives in a `@push('head')`\n\ninside a model accessor, it's easy to lose track of what ends up on the page.\n\nThis 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](https://danielpetrica.com/laraplugins-performance-audit/) i was not prepared for the site growth and\n\nI wanted a single source of truth for `<head>`\n\nmetadata. `laravel/head`\n\nprovides exactly that.\n\n## Layer 1: Global Defaults\n\nEvery page on LaraPlugins.io inherits from `Head::defaults()`\n\nin `AppServiceProvider`\n\n:\n\n``` php\nHead::defaults(fn (HeadBuilder $head) => $head\n    ->title('LaraPlugins.io')\n    ->description('Discover the best Laravel packages. Health scores, security advisories, and version history for 82,000+ Composer packages. Available via web, MCP, and markdown.')\n    ->canonical(forceHttps: true)\n    ->robots('index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1')\n    ->og(siteName: 'LaraPlugins.io', type: OgType::Website, locale: 'en_US')\n    ->twitter(card: TwitterCard::SummaryWithLargeImage)\n    ->meta('twitter:site', '@LaraPlugins')\n    ->meta('twitter:creator', '@danielpetrica')\n    ->meta('author', 'Daniel Petrica')\n    ->meta('publisher', 'Daniel Petrica')\n    ->meta('pinterest', 'nopin')\n    ->meta('article:author', 'https://danielpetrica.com')\n    ->meta('article:section', 'Technology')\n    ->meta('article:tag', 'Laravel,PHP,Plugin,Directory')\n    ->link('sitemap', 'https://laraplugins.io/sitemap.xml', ['type' => 'application/xml'])\n    ->preconnect('https://hello.danielpetrica.com')\n    ->preconnect('https://umami-unr.danielpetrica.com')\n);\n```\n\nThis 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)`\n\ncall ensures every page's canonical URL uses HTTPS regardless of how the request arrived.\n\nThe resource hints (`preconnect`\n\n, `dnsPrefetch`\n\n) are particularly nice. Previously these were hardcoded in the layout. Now they live in one place with the rest of the `<head>`\n\nconfiguration.\n\n## Layer 2: The @head Directive\n\nThe layout change is almost too simple to write about:\n\n```\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    @head\n    @stack('head')\n    @vite(...)\n</head>\n```\n\nThe `@head`\n\ndirective 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')`\n\nremains only for the few complex model schemas not yet migrated (see “What Stayed Custom”); everything else is gone.\n\nIf you forget `@head`\n\nin your layout, the page ships with no `<title>`\n\n, 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`\n\nis a hard failure you'll catch immediately. You will have less change of a silent omission of an `og:image`\n\nyou'll discover in the Slack preview two days later.\n\n## Layer 3: Route-Level Metadata\n\nStatic marketing pages don't need a controller to declare their SEO metadata. `laravel/head`\n\nprovides `withHead()`\n\ndirectly on routes:\n\n``` php\nRoute::get('/mcp', [MarketingController::class, 'mcp'])\n    ->name('marketing.mcp')\n    ->withHead(\n        title: 'LaraPlugins MCP — AI-native Plugin Discovery',\n        description: 'Reduce LLM hallucinations by letting your AI coding agent search 82,000 Laravel packages through the LaraPlugins MCP server.',\n        ogImage: '/og/feature/mcp',\n    );\n```\n\nThis is the package's killer feature for sites with many static pages. No controller. No View composer. No `@section('title')`\n\nin the Blade template. The route declares its own metadata, and `@head`\n\nrenders it.\n\n## Layer 4: Controller-Level Metadata\n\nEleven controllers handle the dynamic pages on LaraPlugins.io. Each one uses the fluent API to override defaults per request:\n\n``` php\n// Plugin detail page — PluginListingController\nHead::title($plugin->getSeoTitleAttribute())\n    ->description($plugin->getSeoDescriptionAttribute())\n    ->ogImage(route('og.plugin', [\n        'vendor' => $plugin->vendor->name,\n        'package' => $plugin->clean_name,\n    ]))\n    ->schema($this->pluginBreadcrumbs($plugin))\n    ->schema($this->pluginFaqSchema($plugin));\n\n// Blog post — BlogController\nHead::title(BlogBusiness::seoTitle($post) . ' — LaraPlugins Blog')\n    ->description(BlogBusiness::seoDescription($post))\n    ->canonical($post->canonical_url ?? route('blog.show', $post->slug))\n    ->og(type: OgType::Article, image: BlogBusiness::ogImage($post))\n    ->link('alternate', route('blog.rss'), ['type' => 'application/rss+xml'])\n    ->schema(Schema::breadcrumbs()->items([...]));\n```\n\nThe 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')`\n\nin Blade templates receiving data from three different sources.\n\n## Layer 5: Schema.org JSON-LD\n\nThis is where the package genuinely shines. `Head::schema()`\n\naccepts schema objects and renders them as JSON-LD. I built a thin `Schema::`\n\nabstraction layer on top:\n\n``` php\n// Breadcrumbs — used on 7 page types\nHead::schema(Schema::breadcrumbs()->items([\n    ['name' => 'Home', 'url' => route('home')],\n    ['name' => 'Plugins', 'url' => route('plugins')],\n    ['name' => $plugin->vendor->name, 'url' => route('vendor', $plugin->vendor->slug)],\n    ['name' => $plugin->clean_name, 'url' => route('plugin', [...])],\n]));\n\n// FAQ — 9 dynamic questions per plugin\nHead::schema(Schema::faq()->questions([\n    ['question' => \"What is {$plugin->clean_name}?\", 'answer' => $plugin->description],\n    // ... 8 more dynamic questions\n]));\n\n// WebSite + Organization — homepage\nHead::schema(Schema::webSite()->name('LaraPlugins.io')->url('https://laraplugins.io')->potentialAction([...]));\nHead::schema(Schema::organization()->name('LaraPlugins.io')->url('https://laraplugins.io')->sameAs([...]));\n```\n\nBuilder |\nWhere It's Used |\n|---|---|\n| Schema::breadcrumbs() | Plugin detail, blog, security advisory, vendor, maintainer, plugin list |\n| Schema::faq() | Plugin detail (9 dynamic Qs), blog posts with FAQ data, homepage |\n| Schema::webSite() | Homepage |\n| Schema::organization() | Homepage |\n\n## Layer 6: Error Pages\n\n``` php\nHead::errors(fn (ErrorPages $errors) => $errors\n    ->defaults(robots: 'noindex, follow')\n    ->status(\n      404,\n      title: 'Page Not Found',\n      description: 'The page you are looking for does not exist.'\n    )\n    ->status(\n      500, \n      title: 'Server Error', \n      description: 'Something went wrong on our end.'\n    )\n    ->status(\n      503, \n      title: 'Service Unavailable', \n      description: 'LaraPlugins is temporarily unavailable.'\n    )\n);\n```\n\nThe `defaults(robots: 'noindex, follow')`\n\ncall ensures every error page is excluded from search indexing . With a single line that the package replace a manual `<meta name=\"robots\">`\n\ntag in the error layout which honestly i always forget to add. .\n\n## What Stayed Custom\n\nNot everything moved into the package. Here's what I kept custom, and why:\n\n**OG Image Generation**: 8 SVG templates rendered by an`OpenGraphController`\n\n. The package doesn't generate images; it receives URLs via`Head::ogImage()`\n\n.**Sitemaps:** Our`SitemapController`\n\nassigns health-score-based priorities. The package doesn't model sitemap logic.**RSS Feeds:** Four feeds generated independently. The package handles feed discovery via`Head::link('alternate', ...)`\n\n.**Model SEO Accessors:**`getSeoTitleAttribute()`\n\n,`getSeoDescriptionAttribute()`\n\n,`toSchemaArray()`\n\non Plugin, Vendor, and Contributor models.**HTML Microdata:**`itemprop`\n\n/`itemscope`\n\non breadcrumbs. The package focuses on JSON-LD.**Complex Model Schemas:** SoftwareApplication, BlogPosting still via`@push('head')`\n\n. Future migration candidate.\n\n## Migration Verification\n\nMigrating 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:\n\n**Rendered output diffs.** Crawled the top 500 pages by traffic before and after, diffing rendered`<head>`\n\noutput.**Schema validation.** Every JSON-LD page run through Google's Rich Results Test.**HTTP tests.** Key page types now assert metadata:\n\n``` php\ntest('plugin detail page has correct head metadata', function () {\n    $plugin = Plugin::factory()->create();\n    $this->get(route('plugin', [\n        'vendor' => $plugin->vendor->name,\n        'package' => $plugin->clean_name,\n    ]))\n        ->assertSee('<title>' . $plugin->getSeoTitleAttribute() . '</title>', false)\n        ->assertSee('<meta name=\"description\"', false)\n        ->assertSee('<meta property=\"og:title\"', false)\n        ->assertSee('<script type=\"application/ld+json\">', false);\n});\n```\n\n**Search Console monitoring.** 72 hours post-deploy. No errors, no drop in valid pages.\n\n## What I'd Do Differently\n\n**Start with the test suite.** Write HTTP assertions for`<head>`\n\nmetadata 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')`\n\n.`@head`\n\nand`@push('head')`\n\ncoexist without issues.\n\n## The Real Challenge: Ongoing SEO at Scale\n\nOk 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.\n\nLaraPlugins.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`\n\nor 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.\n\nSo 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.\n\nIt crawls every page starting from the sitemap similarly to the way Google does.\n\nIt checks titles, canonicals, Open Graph tags, and structured data, and flags the broken ones before Google notices instead of after.\n\nIt'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.\n\nThe migration solved how metadata gets built. This is how I make sure it stays correct.\n\nIf you want to check your site or homepage really quick use this button to do so\n\n[Do a free audit of your site](https://seo.danielpetrica.com/?ref=danielpetrica.com)\n\n## Should You Use It?\n\nIf you ship public-facing Laravel pages, yes. Even at v0.2.1, `laravel/head`\n\nreplaces a surprising amount of boilerplate:\n\n- No more custom Blade components for\n`<head>`\n\nmetadata - No more View composers distributing SEO defaults\n- No more\n`@stack('head')`\n\nscattered across layouts, controllers, and models - No more remembering to add Twitter cards to that one marketing page\n- Schema.org JSON-LD through a single\n`Head::schema()`\n\ncall\n\nThe 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>`\n\ntag — all of it — through one fluent API. Everything else lives where it belongs: in dedicated controllers, models, and services.\n\nFor 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.\n\n*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.*", "url": "https://wpnews.pro/news/migrating-the-seo-on-page-from-custom-codeto-laravel-head-for-laraplugins", "canonical_source": "https://danielpetrica.com/laravel-head-package-migration/", "published_at": "2026-08-25 00:52:30+00:00", "updated_at": "2026-08-25 01:13:11.478297+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Laravel", "LaraPlugins.io", "Daniel Petrica", "Laravel/Head", "Google"], "alternates": {"html": "https://wpnews.pro/news/migrating-the-seo-on-page-from-custom-codeto-laravel-head-for-laraplugins", "markdown": "https://wpnews.pro/news/migrating-the-seo-on-page-from-custom-codeto-laravel-head-for-laraplugins.md", "text": "https://wpnews.pro/news/migrating-the-seo-on-page-from-custom-codeto-laravel-head-for-laraplugins.txt", "jsonld": "https://wpnews.pro/news/migrating-the-seo-on-page-from-custom-codeto-laravel-head-for-laraplugins.jsonld"}}