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 and tag in the matters a little.
For the past 8 months, I handled this with a homegrown system: a custom Blade component, View composers injecting defaults, manual 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 was assembled from multiple sources:
charset="utf-8">
name="viewport" content="width=device-width, initial-scale=1.0">
@yield('title', 'LaraPlugins.io')
: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'])
The 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 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 configuration.
Layer 2: The @head Directive
The layout change is almost too simple to write about:
charset="utf-8">
name="viewport" content="width=device-width, initial-scale=1.0">
@head
@stack('head')
@vite(...)
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 , 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 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 an
OpenGraphController. The package doesn't generate images; it receives URLs viaHead::ogImage(). -
Sitemaps: Our
SitemapControllerassigns 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', ...). -
Model SEO Accessors:
getSeoTitleAttribute(),getSeoDescriptionAttribute(),toSchemaArray()on Plugin, Vendor, and Contributor models. -
HTML Microdata:
itemprop/itemscopeon 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
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('' . $plugin->getSeoTitleAttribute() . '', false)
->assertSee(', false)
->assertSee(', false)
->assertSee('
