0% прочитано

Laravel hreflang і canonical URLs: SEO для мультимовних сторінок без дублів

У мультимовному Laravel-проєкті недостатньо просто мати окремі UK та EN URLs. Розбираю, як я налаштувала canonical і hreflang у ICanUp, зв’язала мовні версії сторінок, додала x-default і перевірила, щоб search engines не сприймали localized pages як дублікати.

4 вересня 2026 р. 7 хв читанняЛаравель

Після того як у ICanUp з'явилися локалізовані public routes, одного правильного URL для сторінки вже було недостатньо.

Один Post має спільний slug, але доступний як окрема українська та англійська сторінка. Потрібно було пояснити search engine дві речі одночасно: який URL є canonical для поточної мовної версії і які інші URLs є її перекладами.

Саме тут у моїй SEO infrastructure зустрілися canonical, hreflang, x-default і Laravel localization routing.

У моєму випадку canonical не об'єднує українську й англійську сторінки в один URL. Кожна locale має свій self-canonical URL, а hreflang зв'язує ці URLs між собою.

Ця стаття продовжує серію Laravel SEO Infrastructure. Раніше я вже розбирала автоматичну відправку URL через IndexNow і locale-aware lastmod у sitemap.

Проблема почалася з двох правильних URL однієї сутності

Після переходу на спільний slug структура Post у ICanUp стала дуже простою.

Один Post - два locale URLs
Postslug: laravel-indexnow ukhttps://example.com/posts/laravel-indexnow enhttps://example.com/en/posts/laravel-indexnow

На рівні domain це той самий Post і той самий canonical slug.

Але для search engine це два окремі public URLs із різним localized content.

Тому мені не можна було просто вибрати український URL як canonical для обох сторінок. Це зруйнувало б саму модель окремих мовних версій.

Перша важлива річ: canonical не повинен вказувати всі locale на default URL

До
text
Неправильно uk pagecanonical → /posts/laravel-indexnow en pagecanonical → /posts/laravel-indexnow
Після
text
У моїй моделі правильно uk pagecanonical → /posts/laravel-indexnow en pagecanonical → /en/posts/laravel-indexnow
Якщо localized pages є окремими valid indexable сторінками, не варто використовувати canonical як спосіб прибрати дубль між мовами. Для зв'язку перекладів існує hreflang.

Кожна мовна версія отримала свій self-canonical

Для української сторінки canonical формується з українського public route.

Для англійської - з англійського.

У ICanUp українська є default locale, тому її URL не має prefix. Англійська використовує /en.

Це правило належить localization infrastructure, а SEO layer лише використовує вже правильно сформовані public URLs.

HTML - Canonical для default locale
<link    rel="canonical"    href="https://example.com/posts/laravel-indexnow">
HTML - Canonical для English locale
<link    rel="canonical"    href="https://example.com/en/posts/laravel-indexnow">

Той самий locale-aware URL я використовую і там, де потрібен canonical public address сторінки, наприклад для `og:url`.

А hreflang описує зв'язок між мовними версіями

Canonical відповідає на питання який URL є основним для цієї конкретної сторінки.

Hreflang відповідає на інше питання: які інші public URLs є мовними версіями цього самого content.

Тому на обох locale pages я генерую однаковий набір alternate links, але canonical у кожної сторінки свій.

HTML - Hreflang для uk та en
<link    rel="alternate"    hreflang="uk"    href="https://example.com/posts/laravel-indexnow"> <link    rel="alternate"    hreflang="en"    href="https://example.com/en/posts/laravel-indexnow">

Canonical визначає поточну сторінку. Hreflang визначає її мовних сусідів.

Спільний slug сильно спростив цей contract

Раніше в проєкті залишалася legacy-ідея translated slugs.

Під час інтеграції свого пакету avn/laravel-localization я перевела Posts і Categories на один product-owned slug для всіх локалей.

Тепер мовні URLs відрізняються route prefix, а не значенням slug. Це прибрало ще одне джерело складності з canonical, hreflang, sitemap, RSS та language switcher.

Shared slug contract
sluglaravel-indexnow uk route/posts/laravel-indexnow en route/en/posts/laravel-indexnow
Нові hreflang URLs у ICanUp не генеруються зі старих translated slugs. Для Post і Category source of truth - shared canonical slug.

URL я не стала складати вручну

Окремо я хотіла уникнути конструкцій на кшталт якщо locale en - додати /en.

Мод префікса локалі за замовчуванням, увімкнені локалі та варіанти локалізованих маршрутів вже знають рівень локалізації.

Тому hreflang generation використовує чинну інфраструктуру маршрутизації з урахуванням локалізації.

SEO layer не повинен мати свою другу версію правил локалізації URL.

Правильна межа відповідальності
Post / Page / Categorylocalized route generationcanonical public URLsSEO layercanonical + hreflang
Якщо URL уже має canonical generator у routing/localization layer, hreflang краще будувати через нього. Ручна конкатенація locale prefixes майже завжди створює другий source of truth.

Для x-default я використала default locale

У ICanUp default locale - uk.

Тому x-default вказує на canonical URL default locale. Він не створює третю сторінку і не має окремого route.

Це просто ще один alternate signal для вже існуючого default URL.

html
<link    rel="alternate"    hreflang="x-default"    href="https://example.com/posts/laravel-indexnow">

Цікаво, що для hreflang повтор default URL під `uk` і `x-default` має різну семантику, тому обидва links потрібні. А в моєму IndexNow URL batch `x-default` навпаки не додається окремим URL, бо там потрібен список реальних унікальних targets.

Недоступну мовну версію не можна рекламувати через hreflang

Ще один важливий edge case з'явився з Pages.

Наявність locale в системі не означає автоматично, що конкретна Page має доступну public version цією мовою.

Тому для content, де translation може бути відсутнім, hreflang повинен описувати реально доступні localized pages, а не генерувати URL, який приведе до 404 або неявного fallback.

До
text
Неправильно Page has only uk public content hreflang uk → validhreflang en → generated anyway → 404
Після
text
Правильно Page has only uk public content hreflang uk → validhreflang en → not exposed
Hreflang не повинен створювати обіцянку мовної версії, якої public routing насправді не може віддати.

Один SEO контракт працює для різних public content types

Після цього я не хотіла створювати окрему hreflang implementation для кожного нового public module.

Posts, Categories, Pages, а пізніше і Post Series повинні використовувати одну SEO infrastructure та свої existing public URL rules.

Відмінність лише в тому, які локалізовані варіанти реально доступні для конкретної entity.

Content

URL rule

hreflang rule

Post

shared slug + locale route

доступні варіанти локалі

Category

shared slug + locale route

доступні варіанти локалі

Page

canonical Page slug + locale route

лише реально доступні переклади

Post Series

shared public slug + locale route

доступні варіанти локалі

Canonical і hreflang залишилися в спільному SEO layer

Ще одним принциповим рішенням було не переносити цю логіку в окремі Vue pages.

Public controller або presenter готує SEO контракт, існуючий SEO layer формує метадані, а frontend лише рендерить отримані значення.

Завдяки цьому Page, Post або Series не мають власної незалежної реалізації canonical/hreflang.

SEO data flow
Public entitypublic visibility + localecanonical localized URLsshared SEO metadatacanonicalhreflang ukhreflang enx-default

Потім потрібно було переконатися, що ці tags реально є в HTML

У ICanUp SEO metadata серверно рендеряться через Inertia SSR.

Для search engine для мене недостатньо було побачити правильний canonical у DevTools після виконання JavaScript. Я хотіла, щоб canonical і alternate links були присутні вже в початковому server-rendered <head>.

BASH - Перевірка server-rendered head
curl -s https://example.com/posts/laravel-indexnow \  | sed -n '/<head>/,/<\/head>/p'
HTML - Очікувані SEO links
<link    rel="canonical"    href="https://example.com/posts/laravel-indexnow"> <link    rel="alternate"    hreflang="uk"    href="https://example.com/posts/laravel-indexnow"> <link    rel="alternate"    hreflang="en"    href="https://example.com/en/posts/laravel-indexnow"> <link    rel="alternate"    hreflang="x-default"    href="https://example.com/posts/laravel-indexnow">
Після SSR перевірки canonical і hreflang можна побачити без виконання JavaScript. Для мене це важлива частина SEO contract, а не лише frontend state.

Автоматичними tests я зафіксувала саме URL behavior

  • Alternate URL існує для кожної підтримуваної та доступної locale.

  • Default locale URL не отримує зайвий prefix.

  • English URL використовує /en.

  • Post і Category використовують shared canonical slug.

  • Legacy translated slugs не потрапляють у нові hreflang URLs.

  • x-default вказує на canonical URL default locale.

  • Disabled locale не потрапляє в metadata.

  • Недоступна translation не створює broken alternate URL.

  • Canonical відповідає активній locale.

  • Existing SEO metadata не регресує.

Помилки, яких я тепер уникаю

  • Не ставити default-locale canonical на всі мовні версії.

  • Не використовувати hreflang URL з іншої slug architecture.

  • Не конкатенувати locale prefix вручну в SEO code.

  • Не додавати disabled locale.

  • Не генерувати alternate для translation, якої немає.

  • Не забувати x-default.

  • Не перевіряти SEO metadata тільки після client-side render.

  • Не створювати окремий hreflang service для кожного нового content module.

Коли з'явилися Post Series, нової SEO-системи вже не знадобилося

Це стало хорошою перевіркою архітектури.

Коли я додала public Post Series, для них уже можна було повторно використати той самий контракт: localized canonical, hreflang для uk та en, x-default, Open Graph і sitemap.

Тобто SEO infrastructure розширилася новим content type без появи паралельної Series-specific логіки.

Що я залишила собі на майбутнє

  • Canonical і hreflang вирішують різні задачі.

  • Кожна valid localized page може мати свій self-canonical.

  • Hreflang повинен використовувати ті самі URLs, що й public routing.

  • Default locale prefix rules не повинні дублюватися в SEO layer.

  • x-default може вказувати на той самий URL, що й default locale alternate.

  • Shared slug значно спрощує multilingual SEO contract.

  • Недоступні locale variants не потрібно вигадувати.

  • SEO metadata мають бути присутні в server-rendered HTML.

Висновок

Коли я починала multilingual SEO, canonical і hreflang здавалися двома невеликими tags у <head>.

У реальному Laravel-проєкті головне виявилося не написати ці tags, а не створити для них окрему URL reality.

У ICanUp routing залишається джерело істини: shared slug належить content entity, localization layer формує правильний uk або en public URL, а SEO layer використовує ці URLs для self-canonical, hreflang і x-default.

Завдяки цьому language switcher, canonical, hreflang, sitemap, IndexNow і нові public content types говорять про одну й ту саму структуру сайту.

Для multilingual SEO я не хочу окремо будувати SEO URLs. Я хочу, щоб SEO точно описувало ті public URLs, які вже є правдою для застосунку.