Laravel SEO: a complete guide to meta tags, sitemaps, schema and rendering (2026)
A practical Laravel SEO guide: meta tags and canonicals in Blade, spatie/laravel-sitemap, staging noindex, JSON-LD, redirects, Inertia SSR and hreflang.
Laravel SEO comes down to doing on purpose what a WordPress plugin does for you by default: a unique title, description and canonical URL on every page, an XML sitemap, environment-aware robots rules, structured data, one clean URL per page, and HTML that already contains your content when a crawler asks for it. Laravel gives you full control over all of this, but none of it is switched on out of the box. This guide covers each piece of Laravel SEO optimization with code that runs on Laravel 12 and 13, plus the decisions (Blade, Livewire or Inertia; package or hand-written) that decide whether the work pays off.
Quick answer: the Laravel SEO checklist
If you only read one section, read this one. A Laravel app is in good shape for search when:
- Every indexable page prints its own
<title>, meta description and self-referencing canonical, set from the controller or model, never hard-coded in the layout. - Open Graph and Twitter card tags are in the server-rendered HTML.
- An XML sitemap is regenerated on a schedule, listed in robots.txt and submitted in Search Console.
- robots.txt and the
X-Robots-Tagheader change with the environment, so staging never gets indexed. - JSON-LD structured data is built from your Eloquent models, not pasted by hand.
- Each page has exactly one URL: HTTPS, one host, one trailing-slash rule, and a 301 for anything that moved.
- Paginated pages have their own canonical; filter and sort variations are kept out of the index.
- Pages that should rank are rendered on the server (Blade, Livewire, or Inertia with SSR).
- Core Web Vitals pass on real mobile devices.
- Multilingual sites have hreflang with return links.
What Laravel gives you for SEO, and what it doesn't
Laravel handles the foundations well. Routing gives you readable URLs, and route model binding lets you look records up by slug instead of ID:
// routes/web.php
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;
Route::get('/blog/{post:slug}', [PostController::class, 'show'])->name('posts.show');
Generate the slug once with Str::slug($title) when the record is created and store it in a unique column. Do not regenerate it when the title changes, or every edit becomes a broken URL.
What Laravel does not give you is everything a plugin like Yoast or Rank Math adds to WordPress: per-page meta fields, a sitemap, schema, a redirect manager and a "discourage search engines" switch for staging. In Laravel you write those or install a package for each. That is a real advantage (no plugin bloat, and you decide every tag) and a real risk: if nobody owns SEO optimization on the project, the app ships with one layout, <title>{{ config('app.name') }}</title>, and the same title on every page. If you are weighing the two platforms for a new build, I compare them in Laravel vs WordPress.
Titles, meta descriptions and canonical URLs in Blade
The cleanest pattern is a layout component that accepts SEO values as props with sensible defaults. Every page then passes what it knows.
{{-- resources/views/components/layout.blade.php --}}
@props([
'title' => config('app.name'),
'description' => null,
'canonical' => null,
'robots' => null,
'image' => asset('images/og-default.png'),
])
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title }}</title>
@if ($description)
<meta name="description" content="{{ $description }}">
@endif
<link rel="canonical" href="{{ $canonical ?? url()->current() }}">
@if ($robots)
<meta name="robots" content="{{ $robots }}">
@endif
<meta property="og:type" content="website">
<meta property="og:title" content="{{ $title }}">
<meta property="og:url" content="{{ $canonical ?? url()->current() }}">
<meta property="og:image" content="{{ $image }}">
@if ($description)
<meta property="og:description" content="{{ $description }}">
@endif
<meta name="twitter:card" content="summary_large_image">
@stack('head')
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
{{ $slot }}
</body>
</html>
A post page then fills the props from the model:
{{-- resources/views/posts/show.blade.php --}}
<x-layout
:title="($post->seo_title ?? $post->title) . ' | ' . config('app.name')"
:description="$post->meta_description ?? Str::limit(strip_tags($post->excerpt), 155)"
:canonical="route('posts.show', ['post' => $post->slug])"
:image="$post->cover_url ?? asset('images/og-default.png')"
>
<article>
<h1>{{ $post->title }}</h1>
{!! $post->body_html !!}
</article>
</x-layout>
Three details matter more than they look:
url()->current()drops the query string. That is what you want for tracking parameters like?utm_source=, so the canonical always points at the clean URL. It is wrong for paginated lists, which I cover below.- The canonical must be absolute and use your production host. Set
APP_URLcorrectly, and if the app sits behind a load balancer or Cloudflare, trust the proxy so Laravel generateshttps://URLs. In Laravel 11 and later that lives inbootstrap/app.php:$middleware->trustProxies(at: '*');(narrowatto your proxy IPs if you can). - Store
seo_titleandmeta_descriptionas optional columns with fallbacks to the real title and excerpt. Editors can then override them without a deploy, and nothing is ever empty.
Should you use a Laravel SEO package?
A package saves time on content-heavy sites. A hand-written component is often enough for a marketing site with twenty routes. These are the options I would consider in 2026, all checked against Packagist this month:
| Option | Fits | Notes |
|---|---|---|
| ralphjsmit/laravel-seo (opens in a new tab) | Blogs, catalogs, anything model-driven | HasSEO trait stores SEO fields per model; {!! seo()->for($post) !!} prints title, meta, Open Graph and a self-referencing canonical. Supports Laravel 11 to 13. |
| artesaos/seotools (opens in a new tab) | Setting tags from controllers | SEOMeta, OpenGraph, TwitterCard and JsonLd facades. Supports Laravel 10 to 13. |
| spatie/schema-org (opens in a new tab) | JSON-LD only | Fluent, typed builder for every schema.org type. Version 5 needs PHP 8.4. |
| Your own Blade component | Small sites, full control | No dependency; you own every tag. |
Whichever Laravel SEO package you pick, check that it outputs exactly one title, one description and one canonical per page. Two packages fighting over the <head> is a common source of duplicate tags.
Open Graph and social cards
Open Graph tags decide how a link looks when it is shared on LinkedIn, Facebook, WhatsApp, Slack or X. They do not affect rankings directly, but they affect whether people click. The rule for Laravel apps is simple: these tags must be in the HTML the server sends, because link-preview crawlers generally do not run JavaScript. That matters most for Inertia and SPA front ends (covered below).
Keep og:url equal to the canonical, use an absolute URL for og:image, and make the image a real, crawlable file. A 1200 by 630 pixel image is the common choice for large cards.
XML sitemaps with spatie/laravel-sitemap
For a Laravel sitemap, spatie/laravel-sitemap (opens in a new tab) is the standard choice. The current major version (8.x) requires PHP 8.4 and Laravel 12 or 13; on an older stack Composer will pick an older version, so check that version's README.
composer require spatie/laravel-sitemap
The package can crawl your site with SitemapGenerator::create('https://example.com'), which is fine for a small brochure site. For an application I prefer building the sitemap from the database: it is faster, it only lists what you choose, and lastmod comes from real timestamps.
<?php
namespace App\Console\Commands;
use App\Models\Post;
use Illuminate\Console\Command;
use Spatie\Sitemap\Sitemap;
use Spatie\Sitemap\Tags\Url;
class GenerateSitemap extends Command
{
protected $signature = 'sitemap:generate';
protected $description = 'Write public/sitemap.xml from the database';
public function handle(): void
{
$sitemap = Sitemap::create()
->add(Url::create(url('/')))
->add(Url::create(url('/services')))
->add(Url::create(url('/blog')));
Post::query()
->where('status', 'published')
->select(['id', 'slug', 'updated_at'])
->lazyById(500)
->each(fn (Post $post) => $sitemap->add(
Url::create(route('posts.show', ['post' => $post->slug]))
->setLastModificationDate($post->updated_at)
));
$sitemap->writeToFile(public_path('sitemap.xml'));
$this->info('Sitemap written.');
}
}
In Laravel 11 and later, schedule it in routes/console.php, and make sure the server cron runs php artisan schedule:run every minute:
use Illuminate\Support\Facades\Schedule;
Schedule::command('sitemap:generate')->daily();
Practical notes from building these:
- Skip
setPriority()andsetChangeFrequency(). The package still supports them, but Google's sitemap documentation (opens in a new tab) says it ignores both. It does uselastmodwhen it is consistently accurate, so only bumpupdated_atfor meaningful content changes. - Only list canonical, indexable URLs that return 200. No redirects, no noindexed pages, no filtered listings.
- Split large sites. A single sitemap is limited to 50,000 URLs or 50 MB uncompressed. Write one file per content type and reference them from an index:
use Spatie\Sitemap\SitemapIndex;
SitemapIndex::create()
->add('/sitemap-pages.xml')
->add('/sitemap-posts.xml')
->add('/sitemap-products.xml')
->writeToFile(public_path('sitemap.xml'));
Models can also implement Spatie\Sitemap\Contracts\Sitemapable (a toSitemapTag() method) so you can pass a collection straight to ->add(). Fine for small tables; for large ones the lazy query keeps memory flat.
robots.txt and noindex for staging
A fresh Laravel app ships a static public/robots.txt that allows everything. The danger is staging: a public staging site with the same content as production is a duplicate waiting to be indexed. Serve robots rules from a route so they follow the environment, and delete public/robots.txt, because the web server serves the static file before Laravel ever sees the request.
// routes/web.php
Route::get('/robots.txt', function () {
$lines = app()->isProduction()
? ['User-agent: *', 'Disallow: /admin', '', 'Sitemap: ' . url('/sitemap.xml')]
: ['User-agent: *', 'Disallow: /'];
return response(implode("\n", $lines) . "\n")
->header('Content-Type', 'text/plain');
});
Disallow is not the same as noindex. A blocked URL can still appear in results if other sites link to it, and Google cannot see a noindex on a page it is not allowed to crawl. The reliable setup for staging is HTTP basic auth (or an IP allowlist) plus an X-Robots-Tag header from middleware:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NoIndexOutsideProduction
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! app()->isProduction()) {
$response->headers->set('X-Robots-Tag', 'noindex, nofollow');
}
return $response;
}
}
Register it in bootstrap/app.php with $middleware->web(append: [NoIndexOutsideProduction::class]);. The same header is useful in production for private areas, PDFs you do not want indexed, and internal search result pages.
Laravel schema: JSON-LD from Eloquent models
Structured data helps search engines understand what a page is about and makes it eligible for some rich results. Keep it next to the data it describes. A method on the model returns an array, and Blade's @json directive encodes it safely (it escapes < and >, so a title can never break out of the script tag):
// app/Models/Post.php
public function toSchema(): array
{
return [
'@context' => 'https://schema.org',
'@type' => 'BlogPosting',
'headline' => $this->title,
'description' => $this->meta_description,
'datePublished' => $this->published_at->toAtomString(),
'dateModified' => $this->updated_at->toAtomString(),
'mainEntityOfPage' => route('posts.show', ['post' => $this->slug]),
'image' => $this->cover_url,
'author' => [
'@type' => 'Person',
'name' => $this->author->name,
'url' => $this->author->profile_url,
],
];
}
@push('head')
<script type="application/ld+json">@json($post->toSchema())</script>
@endpush
Use the same pattern for Product, BreadcrumbList, Organization on the home page and LocalBusiness on location pages; I keep copy-paste examples for the local types in local business schema examples. The rule that matters: markup must describe what is visible on the page. Then run a few URLs through Google's Rich Results Test (opens in a new tab). If you want this set up across a large app, that is exactly what my schema markup service covers.
Trailing slashes, redirects and one URL per page
Search engines treat /pricing and /pricing/ as two URLs. Pick one form and redirect the other. Laravel's default public/.htaccess already 301s trailing-slash URLs to the slashless version on Apache. Nginx does nothing by default, so add the rule yourself:
# inside the server block, before the Laravel location rules
rewrite ^/(.+)/$ /$1 permanent;
Handle HTTPS and www versus non-www at the server or CDN, not in PHP. For URLs that moved, Laravel has a one-line permanent redirect:
Route::permanentRedirect('/old-pricing', '/pricing');
On a site with many legacy URLs (a migration from WordPress, say), keep a redirects table and a small middleware or fallback route that looks up the path and returns redirect($target, 301). Then the marketing team can add redirects without a deploy. Make sure every redirect goes straight to the final URL in one hop, and update internal links instead of relying on redirects.
Pagination, filters and faceted URLs
Laravel's paginator produces ?page=2 URLs, which is what Google wants: each page in the sequence has its own URL. Two rules from Google's pagination guidance (opens in a new tab):
- Do not point the canonical of page 2 at page 1. Each page should canonicalize to itself, so include the page number:
@php
$canonical = $posts->currentPage() > 1
? url()->current() . '?page=' . $posts->currentPage()
: url()->current();
@endphp
- Link pages with real
<a href>links, which$posts->links()already renders. Google no longer usesrel="next"andrel="prev".
Filters and sorting are where Laravel shops and directories create thousands of near-duplicate URLs (?color=red&sort=price). Keep the canonical on the unfiltered category, add noindex to filtered combinations you do not want in search, and give the few filters people actually search for (say "red running shoes") their own clean routes with unique copy. Do not call ->withQueryString() on links you want crawled unless the parameters are intentional. If you plan to generate many pages from data on purpose, read my programmatic SEO guide first.
Rendering: Blade, Livewire, Inertia and SPAs
This decision has the biggest SEO consequence in a Laravel project, and it is usually made before anyone thinks about search.
| Stack | What a crawler receives | SEO risk |
|---|---|---|
| Blade | Full HTML from the server | Lowest |
| Livewire | Full HTML on first load; updates happen over AJAX | Low, as long as content is in the initial render, not loaded lazily after |
| Inertia with SSR | Full HTML from a Node SSR process, then hydrated | Low, but you run and monitor a second process |
| Inertia without SSR | An empty <div id="app"> plus JSON page data |
High for anything that needs to rank or be shared |
| Separate SPA calling a Laravel API | An empty shell | High |
Google can render JavaScript, but rendering can be deferred, and many other consumers (link previews, some AI crawlers, smaller search engines) do not run it at all. For marketing pages, blogs and product pages, server-rendered HTML is the safe default.
If you are on Inertia, turn SSR on. With the Inertia v3 Vite plugin, the build script becomes vite build && vite build --ssr, and in production you run php artisan inertia:start-ssr under a process monitor such as Supervisor. Set titles with <Head title="..."> and give repeated meta tags a head-key so a page's description replaces the layout's instead of duplicating it. If SSR is not possible yet, at least print the title, description, canonical and Open Graph tags in the root Blade template from the page props, so the first response carries them.
An admin panel behind a login does not need any of this. Mix stacks freely: Blade or Livewire for public pages, Inertia or an SPA for the dashboard.
Core Web Vitals in Laravel apps
Core Web Vitals are Largest Contentful Paint (good at 2.5 seconds or less), Interaction to Next Paint (200 milliseconds or less) and Cumulative Layout Shift (0.1 or less), measured on real users. In Laravel apps the usual causes are on the server side first:
- Slow TTFB from queries. N+1 queries in Blade loops are the classic. Call
Model::preventLazyLoading(! app()->isProduction());in a service provider to catch them in development, and eager load withwith(). - No caching in production. Run
php artisan optimizeon deploy (config, routes, events and views), enable OPcache, and cache expensive fragments or whole public responses. - Heavy front-end bundles. Large Livewire or Inertia bundles hurt INP on mid-range phones. Split code per page and keep third-party scripts in check.
- Images. Give every
<img>awidthandheightto prevent layout shift,loading="lazy"below the fold, andfetchpriority="high"on the hero image that is usually the LCP element.
I go through these in detail in the Laravel performance checklist, and it is the core of my Laravel performance optimization work.
hreflang for multilingual Laravel sites
If you serve the same content in several languages or regions, hreflang tells Google which version to show to whom. Every version lists all versions, including itself, plus an x-default, and the links must be reciprocal. With a {locale} route prefix, a Blade partial can build them from the current route:
@foreach (config('app.supported_locales') as $locale)
<link rel="alternate" hreflang="{{ $locale }}"
href="{{ route(Route::currentRouteName(), array_merge(request()->route()->parameters(), ['locale' => $locale])) }}">
@endforeach
<link rel="alternate" hreflang="x-default"
href="{{ route(Route::currentRouteName(), array_merge(request()->route()->parameters(), ['locale' => config('app.fallback_locale')])) }}">
supported_locales is a config key you add yourself (for example ['en', 'de', 'fr']). Use language codes like en or region codes like en-GB, keep each canonical pointing at its own language version (never at English), and add the alternates to your sitemap with Url::addAlternate($url, $locale) if you prefer listing them there. Translate slugs too if your audience searches in that language.
A pre-launch Laravel SEO checklist
Run through this before the DNS switch. It is short on purpose; the website launch SEO checklist covers the non-Laravel parts.
| Check | How to verify |
|---|---|
APP_ENV=production, APP_DEBUG=false, correct APP_URL |
php artisan about on the server |
No X-Robots-Tag: noindex on production |
curl -I https://example.com |
| robots.txt allows crawling and lists the sitemap | Open /robots.txt |
| Sitemap returns 200 and lists only canonical URLs | Open it, spot-check URLs |
| Unique titles and descriptions | Crawl with Screaming Frog or similar |
Canonicals absolute, https://, correct host |
View source on 5 templates |
Trailing slash and www variants 301 to one URL |
curl -I each variant |
| Old URLs redirect in one hop | Test the top pages from the old site |
| JSON-LD valid | Rich Results Test |
| Content visible with JavaScript disabled | Browser dev tools |
| 404 pages return status 404, not 200 | curl -I https://example.com/nope |
| Search Console verified, sitemap submitted | Search Console |
Frequently asked questions
Is Laravel SEO friendly?
Yes, Laravel is SEO friendly because you control every URL, tag, header and byte of HTML, and Blade renders on the server. The catch is that nothing is automatic: titles, sitemaps, canonicals and redirects have to be built. A Laravel site with these basics done is as SEO friendly as any WordPress site, and often faster.
What is the best Laravel SEO package?
For model-driven sites, ralphjsmit/laravel-seo is the most complete option in 2026, and artesaos/seotools suits apps that set tags from controllers. Add spatie/laravel-sitemap for sitemaps and spatie/schema-org if you want a typed JSON-LD builder. For small sites, a Blade layout component with props is enough and has no dependency.
How do I generate a sitemap in Laravel?
Install spatie/laravel-sitemap, write an Artisan command that adds your routes and published records with Url::create() and setLastModificationDate(), and write the file with writeToFile(public_path('sitemap.xml')). Schedule the command daily and reference the sitemap in robots.txt and Search Console.
Is Inertia.js bad for SEO?
Inertia without server-side rendering is a risk for public pages, because the first response contains no visible content. Inertia with SSR enabled sends full HTML and is fine for SEO. If a page needs to rank or be shared, enable SSR or render that page with Blade.
Does Livewire affect SEO?
Livewire renders components on the server on the first request, so crawlers receive real HTML. Problems only appear when important content is loaded lazily after the page loads, or when navigation happens without real links. Keep primary content in the initial render and use normal <a href> links.
If you would rather have someone check all of this on your app, a technical SEO audit from a developer who writes Laravel every day covers the code and the search side in one pass.