Skip to main content

Laravel Technical SEO: A Practical Guide from URL to Schema

Bilingual routing, canonical and hreflang, sitemap, robots.txt, Core Web Vitals and Schema.org JSON-LD, with real Laravel code.

Updated 19 min readMarkdown

Most Laravel SEO problems aren't about keywords. They're about plumbing: two language versions fighting over the same canonical, a sitemap full of URLs that 404, a hero image that loads last, or structured data with broken JSON. Technical SEO in Laravel comes down to three layers working together: clean, bilingual URLs and routing, fast rendering with good Core Web Vitals, and Schema.org structured data that tells Google what each page is. Get only one of them right and you only get part of the result. This guide walks through all three with real Laravel 12 code, not generic advice.

Why Is Laravel Technical SEO Different?

Laravel is a full-stack framework with its own router. Unlike Next.js or Nuxt, which push you toward a specific rendering model, Laravel leaves rendering decisions to you. The result? Many Laravel sites either jump into an Inertia SPA without thinking about SSR, or render everything in Blade and then get caching wrong.

Three challenges you'll run into more often in Laravel projects:

  1. Bilingual routing: many multilingual projects add a locale to the URL but never set up canonical and hreflang correctly, so Google sees the language versions as duplicates or picks the wrong one.
  2. Caching: Cache::remember() or a full-page/response cache can serve crawlers stale pages if the TTL and invalidation aren't set up properly.
  3. Livewire and Alpine.js: a mix of server rendering and client-side behavior. If important content or metadata only appears after JavaScript runs, you're relying on Google rendering it correctly.

This article is a practical roadmap for fixing the most common mistakes in each layer.

Prerequisites: What You Should Know Before Starting

This guide is for you if you:

  • Have worked with Laravel 10 or newer and are comfortable with routing, middleware, service providers, and Eloquent.
  • Know the basic SEO concepts (canonical, robots, sitemap).
  • Ideally already have a Laravel site running that you want to optimize.

If you're new to Laravel, start with the official docs. If your SEO fundamentals are shaky, skim Google Search Central's SEO Starter Guide first.

The Architecture of Technical SEO in Laravel: Three Main Layers

Technical SEO splits into three layers. None of them works on its own; if one breaks, the others lose most of their effect.

Layer Goal Laravel Tools What Happens If You Get It Wrong
Crawl & Index Google can find and store your pages robots.txt, sitemap, meta robots, canonical Pages don't get indexed or get treated as duplicates
Render & Performance Pages render fast and completely SSR vs CSR, caching, CDN, Core Web Vitals Slow LCP and layout shifts hurt user experience and page-experience signals
Structured Data Google understands what the page is Schema.org via JSON-LD You miss out on rich results

The rest of the article opens up each layer with real code.

Layer 1: URL and Routing: The Foundation of Laravel SEO

The URL is the first thing both users and Google see. A bad URL makes everything else harder.

URLs Should Have a Slug, Not an ID

// ❌ Bad: ID in URL
Route::get('/blog/{id}', [BlogController::class, 'show']);

// ✅ Good: slug in URL
Route::get('/blog/{post:slug}', [BlogController::class, 'show']);

Two main reasons:

  • UX: people remember and trust example.com/blog/laravel-technical-seo-guide far more than example.com/blog/847.
  • Relevance: Google's SEO Starter Guide recommends descriptive words in URLs, because they help people (and Google) understand what the page is about.

Laravel Route Model Binding

Since Laravel 7, when you use {post:slug}, the framework automatically looks the post up by its slug, and if it doesn't exist it returns a 404. That shortens your code and guarantees a real 404 status for missing pages, instead of a "not found" page that returns 200.

Language in the URL: Prefix or Middleware?

For bilingual sites, three patterns are common:

// Pattern 1: locale prefix in the URL (recommended for SEO)
// /fa/blog/...   /en/blog/...
Route::prefix('{locale}')->middleware('locale')->group(function () {
    Route::get('/blog', [BlogController::class, 'index']);
    Route::get('/blog/{post:slug}', [BlogController::class, 'show']);
});

// Pattern 2: subdomain (fa.example.com)
// Requires wildcard DNS: usually for larger projects

// Pattern 3: no prefix, locale from session/cookie
// ❌ Bad for SEO: every language shares one URL

Why is pattern 1 better? Each language gets its own URL, so each one can have its own canonical and its own place in search results. With pattern 3, there's only one URL, so Google can only index one language version of it. Google's guide to localized versions assumes exactly this: a separate URL per language.

A common variant of pattern 1 keeps the default language without a prefix and prefixes the others. That's what this site does: Persian lives at /blog/... and English at /en/blog/...:

// Default language (fa): no prefix
Route::get('/blog/{post:slug}', [BlogController::class, 'show'])->name('blog.show');

// English: /en prefix
Route::prefix('en')->name('en.')->group(function () {
    Route::get('/blog/{post:slug}', [BlogController::class, 'show'])->name('blog.show');
});

Locale Middleware: Validation and Redirect

// app/Http/Middleware/SetLocale.php
public function handle(Request $request, Closure $next)
{
    $locale = $request->route('locale');

    if (!in_array($locale, ['fa', 'en'])) {
        abort(404);
    }

    App::setLocale($locale);
    Carbon::setLocale($locale); // dates in Persian/English

    return $next($request);
}

Note: abort(404) is better than a redirect here, because an invalid locale like /xx/blog should never look like a real page to a crawler.

Layer 2: Meta Tags and OpenGraph: Each Page's Identity

Every Laravel page needs four groups of tags: basic SEO, OpenGraph (Facebook, Telegram, LinkedIn), Twitter Card, and canonical/hreflang.

A Blade Template for Meta Tags

Create a dedicated partial so each page only has to pass its own title and description:

{{-- resources/views/partials/meta.blade.php --}}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

<title>{{ $seoTitle ?? $title }} | {{ config('app.name') }}</title>
<meta name="description" content="{{ $seoDescription ?? Str::limit(strip_tags($excerpt ?? ''), 160) }}">
<meta name="robots" content="{{ $robots ?? 'index, follow' }}">
<link rel="canonical" href="{{ $canonical ?? url()->current() }}">

@if(!empty($alternates))
    @foreach($alternates as $hreflang => $href)
        <link rel="alternate" hreflang="{{ $hreflang }}" href="{{ $href }}">
    @endforeach
@endif

{{-- OpenGraph --}}
<meta property="og:type" content="{{ $ogType ?? 'website' }}">
<meta property="og:title" content="{{ $seoTitle ?? $title }}">
<meta property="og:description" content="{{ $seoDescription ?? '' }}">
<meta property="og:url" content="{{ $canonical ?? url()->current() }}">
<meta property="og:image" content="{{ $ogImage ?? asset('images/og-default.jpg') }}">
<meta property="og:site_name" content="{{ config('app.name') }}">
<meta property="og:locale" content="{{ app()->getLocale() === 'fa' ? 'fa_IR' : 'en_US' }}">

{{-- Twitter --}}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $seoTitle ?? $title }}">
<meta name="twitter:description" content="{{ $seoDescription ?? '' }}">
<meta name="twitter:image" content="{{ $ogImage ?? asset('images/og-default.jpg') }}">

Note: og:locale uses the language_TERRITORY format with an underscore (fa_IR, en_US), not a hyphenated language code.

A Dedicated SEO Service: Not a Helper

// app/Services/Seo/SeoService.php
class SeoService
{
    public function __construct(
        private string $title,
        private string $description,
        private ?string $image = null,
        private string $type = 'website',
        private array $alternates = [],
    ) {}

    public function toArray(): array
    {
        return [
            'seoTitle' => $this->title,
            'seoDescription' => Str::limit($this->description, 160),
            'ogImage' => $this->image ?: asset('images/og-default.jpg'),
            'ogType' => $this->type,
            'alternates' => $this->alternates,
        ];
    }
}

Why a service and not a simple helper? As the project grows, the canonical/hreflang logic gets more complex, and keeping it in Blade or controllers gets messy fast.

Layer 3: Sitemap and Robots.txt

A Dynamic Sitemap in Laravel

The standard package here is spatie/laravel-sitemap. A scheduled command that builds the sitemap from your published posts looks like this:

// app/Console/Commands/GenerateSitemap.php
use Spatie\Sitemap\Sitemap;
use Spatie\Sitemap\Tags\Url;

public function handle(): int
{
    $sitemap = Sitemap::create();

    // Static pages
    $sitemap->add(Url::create(route('home'))
        ->setLastModificationDate(now()));

    // Blog posts (one entry per language URL)
    Post::published()->each(function (Post $post) use ($sitemap) {
        $sitemap->add(Url::create(route('blog.show', $post->slug))
            ->setLastModificationDate($post->updated_at));
        $sitemap->add(Url::create(route('en.blog.show', $post->slug))
            ->setLastModificationDate($post->updated_at));
    });

    $sitemap->writeToFile(public_path('sitemap.xml'));
    return self::SUCCESS;
}

On Laravel 11 and newer, schedule it in routes/console.php:

use Illuminate\Support\Facades\Schedule;

Schedule::command('sitemap:generate')->daily();

Note: according to Google's sitemap documentation, Google ignores <priority> and <changefreq>, but uses <lastmod> when it's consistently accurate. So spend your effort on a correct lastmod, not on priorities. Only add a language URL to the sitemap once that version is actually published; listing URLs that return 404 just wastes crawls.

A Sensible robots.txt

# public/robots.txt
User-agent: *
Disallow: /admin/
Disallow: /api/
Allow: /

Sitemap: https://example.com/sitemap.xml

Two notes:

  • Don't block the paths that serve your CSS and JS (including Livewire's script route under /livewire/). Google needs them to render the page the way users see it.
  • robots.txt is not a security control. It's public, and blocked URLs can still be indexed if other pages link to them. Keep your admin behind authentication and add noindex to anything that must never appear in search (Google's robots.txt intro explains the difference).

Layer 4: Core Web Vitals: Speed, Speed, Speed

Core Web Vitals are Google's three main user-experience metrics. The "good" thresholds below come from web.dev:

Metric Good How to Improve It in Laravel
LCP (Largest Contentful Paint) ≤ 2.5 s WebP images, a prioritized hero image, caching, CDN
INP (Interaction to Next Paint) ≤ 200 ms Less JS, defer non-critical scripts
CLS (Cumulative Layout Shift) ≤ 0.1 width/height on images, font-display: swap

WebP Images With Intervention Image

// composer require intervention/image   (v4 requires PHP 8.3+)
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Format;

$manager = ImageManager::usingDriver(GdDriver::class);

$manager->decodePath($uploadedFile->getRealPath())
    ->encodeUsingFormat(Format::WEBP, quality: 80)
    ->save($path . '.webp');

Note: older tutorials use Image::make(), which is the v2 API; it doesn't exist in current versions. Check the Intervention Image docs for the version you install.

Loading Images Correctly in Blade

The main (LCP) image should load as early as possible; images further down the page can wait:

{{-- Hero / LCP image: no lazy loading, high priority --}}
<img src="{{ $post->hero_image }}"
     alt="{{ $post->title }}"
     width="1200" height="630"
     fetchpriority="high"
     decoding="async">

{{-- Images below the fold --}}
<img src="{{ $image->url }}"
     alt="{{ $image->alt }}"
     width="800" height="450"
     loading="lazy"
     decoding="async">

Note: always set width and height. Without them the browser can't reserve space, and the layout shifts (CLS) when the image arrives. And never put loading="lazy" on the LCP image; it delays the very thing LCP measures.

Vite for Bundling Assets

Laravel ships with Vite. In production, @vite loads built files with a hash in their names (for example app-[hash].js), which means you can cache them aggressively: when the content changes, the filename changes too.

# Long-term caching for Vite's hashed build files
location /build/assets/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

If you use Livewire 3 or newer, Alpine.js is already bundled with Livewire's JavaScript, so don't load a second copy of Alpine yourself.

Layer 5: Schema.org / JSON-LD: Google's Language

Schema.org is the vocabulary Google uses to understand your page. Without it, Google sees a headline and some text. With it, Google knows "this is a technical article by author X, published on date Y, updated on date Z".

Article Schema in Laravel

// app/Services/Seo/SchemaService.php
public static function blogPost(Post $post): string
{
    return json_encode([
        '@context' => 'https://schema.org',
        '@type' => 'TechArticle',
        'headline' => $post->seo_title ?: $post->title,
        'description' => $post->seo_description,
        'image' => [
            '@type' => 'ImageObject',
            'url' => $post->og_image_url,
            'width' => 1200,
            'height' => 630,
        ],
        'datePublished' => $post->published_at?->toIso8601String(),
        'dateModified' => $post->updated_at?->toIso8601String(),
        'author' => [
            '@type' => 'Person',
            'name' => $post->author?->name ?? 'Captain Code',
            'url' => route('about'),
        ],
        'publisher' => [
            '@type' => 'Organization',
            'name' => config('app.name'),
            'logo' => [
                '@type' => 'ImageObject',
                'url' => asset('images/logo.png'),
            ],
        ],
        'mainEntityOfPage' => [
            '@type' => 'WebPage',
            '@id' => route('blog.show', $post->slug),
        ],
    ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

Injecting It in Blade

@if($schema ?? null)
    <script type="application/ld+json">
        {!! $schema !!}
    </script>
@endif

Pick one main type per page (for example TechArticle or BlogPosting, not both for the same article), so the signals don't contradict each other.

To help Google show a breadcrumb path instead of a raw URL under your result:

public static function breadcrumbs(array $items): string
{
    $list = [];
    foreach ($items as $i => $item) {
        $list[] = [
            '@type' => 'ListItem',
            'position' => $i + 1,
            'name' => $item['name'],
            'item' => $item['url'],
        ];
    }
    return json_encode([
        '@context' => 'https://schema.org',
        '@type' => 'BreadcrumbList',
        'itemListElement' => $list,
    ], JSON_UNESCAPED_UNICODE);
}

FAQ Schema: For the FAQ Section

If your article has an FAQ section, you can describe it with FAQPage:

public static function faq(array $faqs): string
{
    return json_encode([
        '@context' => 'https://schema.org',
        '@type' => 'FAQPage',
        'mainEntity' => array_map(fn($q, $a) => [
            '@type' => 'Question',
            'name' => $q,
            'acceptedAnswer' => [
                '@type' => 'Answer',
                'text' => $a,
            ],
        ], array_keys($faqs), $faqs),
    ], JSON_UNESCAPED_UNICODE);
}

Set your expectations, though: since August 2023, Google only shows FAQ rich results for well-known, authoritative government and health websites. For other sites the markup is harmless, but it usually won't produce a visible rich result.

Layer 6: hreflang for Bilingual Sites

Persian/English (or any bilingual) sites must tell Google "these URLs have the same content in different languages". With the default-language-without-prefix pattern from Layer 1, the tags look like this:

<link rel="alternate" hreflang="fa" href="https://example.com/blog/laravel-technical-seo-guide">
<link rel="alternate" hreflang="en" href="https://example.com/en/blog/laravel-technical-seo-guide">
<link rel="alternate" hreflang="x-default" href="https://example.com/blog/laravel-technical-seo-guide">

Generating them in Laravel (route names from the Layer 1 example):

$alternates = [
    'fa' => route('blog.show', $post->slug),
    'en' => route('en.blog.show', $post->slug_en ?: $post->slug),
    'x-default' => route('blog.show', $post->slug),
];

Notes:

  • Include x-default. It tells Google which version to show when no language matches the user (usually the site's primary language).
  • Every language version should list all versions, including itself, and the tags must be reciprocal; otherwise Google may ignore them (Google's localized versions guide).
  • Only output the en tag when the English version is actually published. Google can't use an hreflang that points to a 404, and it's a sign the setup is broken.

Cache and SEO: A Double-Edged Sword

Laravel's caching is excellent, but configured wrong it serves stale content to Google. Three rules:

  1. Never cache without a TTL: Cache::remember('key', 3600, ...), 3600 seconds is one hour.
  2. Invalidate when content changes, and never cache admin or preview pages: when a post is published, clear the related keys. Cache::tags(['public'])->flush() works only with cache stores that support tags (Redis, Memcached); with the file or database store, forget() the specific keys instead. A full-page cache can also break forms by serving an old CSRF token; our guide to Laravel 419 Page Expired covers that case.
  3. Compress responses at the web server instead of "minifying" HTML with regex. A middleware that collapses all whitespace also breaks <pre> code blocks and inline scripts, while gzip or Brotli gives you most of the size win safely:
# nginx: text/html is always compressed once gzip is on
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;

Livewire and SSR: The Specific Challenge of Modern Laravel

The good news first: Livewire components are rendered on the server on the first request, so their HTML is in the page source just like regular Blade. Google sees that content without running any JavaScript.

The risk comes from content that isn't in that first HTML response:

  • Lazy or deferred components (lazy / defer): according to the Livewire docs, they load when they enter the viewport or right after the page loads. Until then, only a placeholder is in the HTML. That's fine for a revenue widget, not for your article body.
  • Content loaded after an interaction or with wire:init: if it only shows up after a click or a follow-up request, don't count on it being indexed.

Fix: render the main content (title, body, headings, links) in the initial HTML, and use lazy loading only for secondary parts like comments or related widgets. <livewire:component /> and @livewire('component') render the same way, so switching between them changes nothing for SEO.

If you use Inertia.js: without SSR, the first HTML response is mostly an empty root element and the content appears only after JavaScript runs. For pages that need to rank, turn on Inertia's server-side rendering.

Test SEO Before Publishing

Before publishing, check:

  1. Google Rich Results Test: https://search.google.com/test/rich-results: validate your structured data.
  2. Lighthouse (Chrome DevTools → Lighthouse): Performance, SEO, Accessibility, and mobile usability. Fix every failed SEO audit rather than chasing a specific score.
  3. PageSpeed Insights: https://pagespeed.web.dev: shows field Core Web Vitals data when your site has enough traffic.
  4. Search Console URL Inspection: see the HTML Google actually rendered and whether the page is indexable. (Google retired its standalone Mobile-Friendly Test in December 2023; Lighthouse covers that now.)

Common Mistakes in Laravel SEO

Mistake Why It Hurts Fix
noindex forgotten on draft pages Google indexes an unfinished page Middleware or layout logic that adds noindex to non-published pages
Wrong canonical on dynamic pages Google treats the wrong URL as the main one Build canonical from route names, not url()->current() (which mirrors whatever host, scheme or path variant was requested)
Sitemap includes 404 URLs Wasted crawls, lower trust in your sitemap HTTP-check URLs (or only add published ones) before writing the sitemap
Schema with broken JSON Google can't parse it, so it's ignored Build it with json_encode() on an array, never by string concatenation, and validate it
Images without alt Hurts both image SEO and accessibility A Blade image component with a required alt
Main content lazy-loaded in Livewire or client-only in Inertia Content missing from the initial HTML Render main content on the first request; Inertia SSR

Final Checklist Before Launching a Laravel Site

  • All URLs use slugs, no IDs
  • Every page has a canonical
  • hreflang for every published language version, reciprocal, with x-default
  • Admin behind auth + noindex; robots.txt doesn't block CSS/JS
  • sitemap.xml is generated, has accurate lastmod, and is referenced in robots.txt
  • WebP images; lazy loading only below the fold
  • Core Web Vitals in the "good" range (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1)
  • Schema.org JSON-LD on every content page, one main type per page
  • OpenGraph and Twitter Card tested, for example with opengraph.xyz
  • Main content in the initial server-rendered HTML
  • gzip or Brotli compression enabled
  • Caches have a sensible TTL and are invalidated on publish

Frequently Asked Questions

Do I have to use a package for Laravel SEO?

No. Most needs can be covered with plain Laravel code. Packages like artesaos/seotools or ralphjsmit/laravel-seo are handy on bigger projects with several developers, but for a medium-sized project a dedicated service plus a Blade partial is enough. Every package means updates, possible bugs, and one more abstraction layer to debug.

What's the best sitemap package for Laravel?

spatie/laravel-sitemap is the de facto standard, and it's what the command above uses. For a very simple site you can also write the XML yourself. More important than the package is keeping the sitemap current, with accurate lastmod values and no 404 URLs.

How do I make Livewire SEO-friendly?

Livewire already renders on the server on the first request. Keep your main content out of lazy/defer components and out of anything that only loads after an interaction, and use lazy loading for secondary parts like comments. Check the result with View Source or Search Console's URL Inspection.

What Schema.org type should I use for an e-commerce site?

For products, use Product with Offer (and AggregateRating only if you have real reviews); for the homepage, Organization with logo and sameAs (your social profiles); for category pages, CollectionPage or ItemList. Product + BreadcrumbList + Organization usually covers it. Follow the official Schema.org Product docs and Google's own structured data guidelines.

How long until I see the results of technical SEO?

There's no fixed timeline. Google has to recrawl and reprocess the affected pages, which can be quick for pages it visits often and much slower for the rest of a site. You can speed up discovery by submitting your sitemap and requesting indexing for key URLs in Search Console. Technical SEO is the foundation: it doesn't rank pages on its own, but content and links can't do their job while it's broken.

Is a CDN necessary for a Laravel site?

It depends on where your visitors are. If most of them are close to your server, a CDN may add little (and a CDN without nearby edge locations can even make things slower). If your audience is spread across countries, Cloudflare or BunnyCDN is worth it. For images, self-hosted WebP with proper cache headers is often enough.

What's the difference between canonical and hreflang?

canonical says "this is the main URL for this content; if the same content exists at several URLs, index this one". hreflang says "this content exists in several languages, and here is each language's URL". On a bilingual site every page has both, and they don't conflict: each language version should be self-canonical (the English page's canonical points to the English URL), while hreflang links the versions together.


If you're working on a Laravel project right now and want to fix its SEO, start with Layer 1, URLs and canonical. If those are wrong, no amount of Schema, Vite, or CDN tuning will stop Google from getting confused. Then add TechArticle (or Article) and BreadcrumbList to every content page. Finally, check Core Web Vitals with Lighthouse and keep iterating.

Want a second pair of eyes on your setup? Paste your meta partial, routes, or the <head> of a page into Nyx, Captain Code's AI assistant and go through canonical, hreflang, and schema with it step by step. If you'd rather have a Laravel site built with this foundation from day one, take a look at custom Laravel web development; and if your site is live but you need someone keeping it fast, patched, and indexable, there's security and maintenance. Either way, you can get in touch here.

If you're still choosing your path in web development, Frontend, Backend, or Full-Stack? and the best programming languages to learn in 2026 are good next reads.

Sources:

Solid guide, and the canonical point is the one I'd underline twice. One gap I kept hitting on marketplace style sites: listing pages with ?sort=, ?filter= and ?page= in the query string. Each combination is a separate URL to Google, and a canonical that just mirrors the request tells Google every variant deserves indexing. I point filtered listings back to the clean base URL now, keeping only the base page canonical. Quietly the biggest crawl budget leak nobody warns you about.