0% прочитано

Laravel SEO для кількох моделей: як я перевикористала одну SEO-архітектуру для Post, Page і PostSeries

SEO в ICanUp починався з Posts, але з появою Pages і PostSeries дублювати meta title, description, Open Graph, fallback, canonical та hreflang у кожному модулі стало небезпечно. Розбираю reusable SEO layer, локалізовані metadata, polymorphic integration, entity-specific fallback і чому спільне збереження SEO не означає однакові SEO-правила для всіх моделей.

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

Коли SEO у ICanUp було потрібне тільки для Posts, окремі поля й логіка не здавалися великою проблемою.

Потім з’явилися повноцінні Pages. Далі PostSeries отримали власні публічні сторінки, canonical, hreflang, sitemap, breadcrumbs, обкладинки й окремі SEO metadata.

У цей момент стало зрозуміло: якщо кожен модуль отримає власну таблицю, власний Admin SEO form, власний resolver і власні fallback rules, через кілька місяців у мене буде кілька схожих, але вже різних SEO-систем.

Тому SEO я почала розглядати не як набір полів конкретного Post, а як повторно використовувану інфраструктуру для різних публічних сутностей.

Повторно використовуваний SEO layer не означає, що Post, Page і PostSeries мають однакові SEO-правила. Спільними можуть бути збереження, локалізація, Admin UI та базовий resolver, а доменні fallback і indexability залишаються відповідальністю конкретної сутності.
Reusable Laravel SEO architecture connecting Post Page and PostSeries to one shared SEO layer with localized metadata resolver canonical hreflang Open Graph and fallback rules
Post, Page і PostSeries використовують спільну SEO-інфраструктуру, але кожна сутність зберігає власний контракт URL, fallback і публічної доступності.

Чому окремий SEO-код у кожній Laravel model швидко стає проблемою

Три майже однакові SEO-системи
Post  SEO fields  SEO validation  SEO form  SEO fallback  OG resolver Page  SEO fields  SEO validation  SEO form  SEO fallback  OG resolver PostSeries  SEO fields

Проблема тут не тільки в кількості коду.

Небезпечніше те, що одна реалізація починає випереджати інші.

  • Post уже підтримує новий Open Graph fallback, а Page ще ні.
  • Page має правильний localized description, а PostSeries використовує стару логіку.
  • Одна форма має character guidance, інша приймає ті самі поля без однакової validation.
  • Один модуль правильно обробляє порожній SEO title, інший повертає пустий <title>.
  • Після зміни social image contract потрібно виправляти кілька незалежних місць.

Дублювання SEO небезпечне не тоді, коли код однаковий. Воно стає небезпечним після першої зміни, яку внесли тільки в одну копію.

Reusable SEO layer: що варто зробити спільним

Спільна частина

Відповідальність

Persistence

Зберігати SEO metadata для різних сутностей

Localization

Окремі UK/EN SEO values

Admin block

Один reusable редактор полів

Validation

Однакові правила для однакових полів

Resolver

Explicit value -> fallback

Social metadata

OG/Twitter presentation

Image resolution

Entity image -> global fallback

Polymorphic SEO relation дозволяє не створювати окрему таблицю для кожної сутності

На рівні Laravel один природний спосіб виразити таку архітектуру - polymorphic relation.

SEO record належить не тільки Post або тільки Page. Він належить будь-якій сутності, яка підтримує спільний SEO contract.

PHP - Спрощена polymorphic relation
// Simplified architecture example.// Exact class and relation names may differ in the project. public function seo(): MorphOne{    return $this->morphOne(        SeoMetadata::class,        'seoable',    );}
Цей код показує архітектурну форму, а не точні назви SEO model, relation або database columns у поточному ICanUp.

Polymorphic persistence не повинна зберігати PHP class names як довічний контракт

Якщо polymorphic type зберігається в базі, я не хочу прив’язувати persistence до повного namespace класу.

Перейменування або переміщення моделі не повинно вимагати міграції всіх polymorphic records лише тому, що змінився PHP namespace.

PHP - Стабільні aliases для polymorphic relations
Relation::enforceMorphMap([    'post' => Post::class,    'page' => Page::class,    'post_series' => PostSeries::class,]);

Локалізоване SEO не повинно змішувати UK та EN metadata

Для мультимовного сайту одного SEO record недостатньо, якщо title і description локалізовані.

Українська сторінка має отримати українські metadata, англійська - англійські.

Спільна сутність, окремі локалізовані SEO values
Post / Page / PostSeries        |        vshared SEO record        |        +--> uk metadata        |        +--> en metadata

Це особливо важливо для meta_title, meta_description, Open Graph title/description та alt text social image, якщо він входить до спільного SEO contract.

Reusable SEO Admin block прибирає дублювання у формах Post, Page і PostSeries

Збереження даних - тільки половина задачі.

Якщо кожен Admin form окремо малює однакові поля SEO, дуже швидко починають відрізнятися підказки, validation, locale switching і поведінка помилок.

Тому PostSeries у моєму випадку не отримував окремий Series-specific SEO editor. Він підключив той самий стандартний SEO block, який уже використовувався для інших публічних сутностей.

Один Admin contract для однакових SEO fields
Post form -------+                 |Page form -------+--> Shared SEO block                 |Series form -----+

Shared persistence не означає однаковий SEO fallback для всіх моделей

Це одна з найважливіших меж усієї архітектури.

Спільний SEO layer може знати, як прочитати explicit metadata. Але він не повинен вгадувати, яке доменне поле є правильним fallback для кожної сутності.

Сутність

Приклад fallback

Post

SEO title -> localized Post title

Page

SEO title -> localized Page title

PostSeries

SEO title -> localized Series title

PostSeries description

SEO description -> localized Series description

SEO resolver краще будувати як explicit value -> domain fallback -> site fallback

Передбачуваний fallback chain
meta title explicit localized SEO title        |        ventity localized title        |        vsite fallback if needed  social image SEO/entity image        |        v

Така послідовність дає дві корисні властивості.

Редактор може явно перевизначити metadata там, де це потрібно, але нова сторінка не залишається без title або description тільки тому, що SEO block ще не заповнили вручну.

PostSeries cover став частиною того самого social image fallback

Коли PostSeries отримали optional cover через спільну Media architecture, для SEO не знадобилося створювати окреме поле "series social image".

Обкладинка Series може використовуватися як social image, а за її відсутності система переходить до глобального fallback.

Це хороший приклад того, як два reusable layers - Media і SEO - можуть доповнювати один одного без нового Series-specific subsystem.

Canonical URL не варто зберігати як довільне SEO field

У моїй архітектурі canonical тісно пов’язаний із public routing і чинною локаллю.

Post, Page і PostSeries мають власні public routes. Тому canonical краще отримувати з реальної URL infrastructure, а не дозволяти редактору вручну вводити довільний URL, який з часом може розійтися з маршрутом.

Canonical походить від маршруту
entity + locale      |      vlocalized public route      |      vabsolute canonical URL

Детальніше цей multilingual contract я вже розбирала у статті про canonical та hreflang у Laravel.

hreflang теж є результатом routing contract, а не просто SEO form

Те саме стосується hreflang.

Якщо сутність доступна українською й англійською, SEO layer має отримати правильні localized public URLs, а не зберігати копії URL у metadata.

URL generation залишається централізованим
UK entity URL    |    +--> hreflang="uk" EN entity URL    |    +--> hreflang="en" default locale URL    |    +--> hreflang="x-default"

Окремий випадок x-default і default locale я розбирала в окремій статті про x-default.

Open Graph type не може бути однаковим для Post і Page

Ще один приклад межі між shared infrastructure і domain semantics - og:type.

Спільний renderer може вивести Open Graph tags, але значення type залежить від сторінки.

Shared renderer, різні entity semantics
Post:og:type = article Page:og:type = website PostSeries:og:type = website

SSR робить reusable SEO layer видимим для crawler без JavaScript

У SPA-подібній архітектурі недостатньо правильно сформувати metadata тільки після виконання JavaScript.

SEO resolver має передати дані в той шлях рендерингу, який формує початковий server HTML.

Саме тому я перевіряю meta tags через звичайний HTTP response, а не тільки в DevTools після hydration.

Окремо я вже розбирала цю проблему у матеріалі про meta tags, які були в браузері, але не були в HTML.

SEO metadata і indexability - різні рівні системи

Наявність SEO record не робить сутність автоматично публічною або indexable.

Draft Post може мати заповнений meta title. Це не означає, що він повинен потрапити в sitemap або стати доступним crawler.

Inactive PostSeries так само може мати metadata, але public eligibility залишається окремим доменним правилом.

Metadata не замінює publication contract
SEO metadata    |    X does not decide publication  Entity public contract    |    +--> route accessibility    +--> robots behavior    +--> sitemap eligibility    +--> indexing notifications

Robots noindex не повинен бути єдиним захистом Draft

noindex керує поведінкою пошукового crawler, але не є access control.

Якщо Draft не повинен бути публічним, звичайний користувач повинен отримати відповідну public visibility behavior незалежно від того, який robots tag сформував SEO layer.

Sitemap теж не повинен читати всі SEO-enabled records

Ще одна можлива помилка - побудувати sitemap навколо самого факту існування SEO metadata.

Правильний напрямок протилежний: спочатку сутність проходить свій public/indexable contract, і тільки потім SEO infrastructure генерує URL та metadata для sitemap.

JSON-LD також не варто змішувати в одну універсальну схему

Reusable SEO infrastructure може мати спільний механізм додавання JSON-LD у сторінку.

Але сама Schema.org структура залежить від сутності.

Для Post доречна article-specific structured data. Для Page або PostSeries інша сторінкова семантика може бути правильнішою. BreadcrumbList при цьому можна будувати через окремий спільний breadcrumbs contract.

Практичну реалізацію BreadcrumbList я окремо описувала у статті про Laravel BreadcrumbList JSON-LD.

Reusable не означає generic array із сотнею nullable fields

Є інша крайність: намагатися передбачити в одному SEO record кожне можливе поле для всіх майбутніх сутностей.

У результаті з’являється великий набір nullable columns, значення яких потрібні тільки одному рідкісному типу сторінки.

Я віддаю перевагу спільному стабільному ядру й розширенню contract лише тоді, коли нова можливість справді стає загальною.

PostSeries став хорошою перевіркою reusable SEO architecture

PostSeries був для мене показовим кейсом.

Публічна SEO integration серії вже існувала: localized routes, canonical, hreflang, sitemap і breadcrumbs.

Коли знадобилося додати повноцінний editable SEO block в Admin, правильним рішенням було не створити ще одну систему, а підключити Series до того самого SEO contract, який уже працював для Post і Page.

Якщо для нової сутності потрібно переписати весь SEO pipeline, reusable architecture насправді ще не reusable.

Додавання нового SEO-enabled content type має бути передбачуваним

Новій сутності не потрібна нова SEO subsystem
New public entity      |      +--> connect shared SEO persistence      +--> connect localized SEO form      +--> define title/description fallback      +--> define public URL      +--> define OG type      +--> define image fallback      +--> define indexability      +--> add regression tests

Транзакція має зберігати entity і SEO metadata як одну операцію

Коли Admin одночасно змінює основні поля сутності й SEO metadata, я не хочу отримати стан, у якому PostSeries уже оновив title, а SEO save впав посередині.

Тому create/update flow має розглядати ці зміни як одну операцію відповідно до загального service pattern.

PHP - Спрощений transactional save
DB::transaction(function () use ($entity, $seoData) {    $entity->save();     $seoService->sync(        $entity,        $seoData,    );});
Назва seoService тут умовна. Приклад показує тільки transaction boundary, а не точну поточну назву сервісу в коді.

Які SEO тести потрібні для Post, Page і PostSeries

Сценарій

Очікування

Explicit SEO title

Перевизначає доменний title

Порожній SEO title

Працює entity fallback

UK metadata

Не змішується з EN

EN metadata

Не змішується з UK

Canonical

Відповідає localized public route

hreflang

Містить правильні мовні URL

Post OG type

article

Page/Series OG type

website

Entity cover

Використовується, якщо contract це підтримує

No image

Працює global fallback

Draft / inactive

SEO metadata не робить сутність indexable

SSR

Meta tags є в початковому HTML

Тести fallback важливіші за happy path із заповненими полями

SEO form майже завжди легко протестувати в сценарії, де всі поля заповнені.

Але реальні регресії частіше з’являються тоді, коли частина metadata порожня.

  • SEO title порожній, description заповнений.
  • SEO description порожній, title заповнений.
  • Немає entity cover.
  • Є UK SEO, але EN використовує fallback.
  • Metadata існує, але entity ще не public.
  • Після зміни title fallback теж оновився.

SEO preview в Admin і реальна public indexability не повинні змішуватися

Можливість адміністратора переглянути Draft через публічний layout корисна для перевірки metadata, ContentBlocks і social preview до публікації.

Але цей доступ не повинен змінювати sitemap, public Search, IndexNow або звичайну доступність URL для гостя.

Preview є способом перевірки майбутньої сторінки, а не способом зробити її indexable раніше часу.

Що я не роблю в reusable Laravel SEO architecture

  • Не створюю окрему SEO table або JSON-схему для кожної нової public entity.
  • Не дублюю однакові SEO fields вручну в кожному Admin form.
  • Не зберігаю canonical URL як довільний текст, якщо URL визначається routing contract.
  • Не зберігаю окремі hreflang URLs у metadata.
  • Не вважаю SEO record доказом того, що сутність public або indexable.
  • Не роблю один універсальний fallback, який ігнорує domain semantics.
  • Не створюю окреме social image поле, якщо entity cover уже входить у спільний Media contract.
  • Не використовую noindex як access control.
  • Не змушую всі сутності використовувати одну JSON-LD schema.
  • Не додаю nullable SEO fields наперед без реального reusable use case.

Checklist для reusable SEO layer у Laravel

  • Визначити стабільне спільне ядро SEO metadata.
  • Підключати різні сутності через reusable relation або contract.
  • Не прив’язувати polymorphic persistence до нестабільних PHP namespaces.
  • Зберігати локалізовані SEO values окремо для кожної locale.
  • Перевикористовувати один Admin SEO block.
  • Перевикористовувати однакові validation rules.
  • Мати передбачуваний explicit -> domain -> site fallback.
  • Залишати canonical і hreflang пов’язаними з routing infrastructure.
  • Дозволяти сутності визначати свій OG type.
  • Повторно використовувати entity cover через Media contract.
  • Не змішувати metadata persistence з public eligibility.
  • Генерувати sitemap тільки з реально indexable сутностей.
  • Перевіряти metadata в SSR HTML.
  • Тестувати порожні explicit fields і fallback paths.
  • Зберігати entity та SEO metadata транзакційно.

Повторно використовувана SEO-архітектура повинна прибирати дублювання технічного механізму, але не стирати доменні відмінності між сторінками.

Висновок: SEO layer має бути спільним, а SEO-рішення залишаються доменними

У ICanUp SEO поступово перестало бути функцією тільки Post.

Pages і PostSeries отримали ті самі базові потреби: локалізовані title та description, Open Graph, social image fallback, canonical, hreflang, sitemap і server-rendered metadata.

Замість того щоб будувати паралельні системи, я залишила спільними persistence, локалізацію, Admin SEO block і базове вирішення metadata. Polymorphic або інша extendable integration дозволяє новим public entities підключатися до цього шару без окремої SEO subsystem.

Але reusable infrastructure не повинна вирішувати все. Post сам визначає свою publication semantics і article metadata. Page має власний URL та fallback. PostSeries має власну назву, description, active state та cover.

Мій головний висновок: повторно використовувати потрібно механізм SEO, а не намагатися зробити всі публічні сутності однаковими.