0% прочитано

Laravel Sitemap lastmod і переклади: коли updated_at не показує реальну дату зміни сторінки

Під час роботи із sitemap я помітила неочевидну проблему: public page могла змінитися через translation table, але updated_at основної моделі залишався старим. Розбираю, як я перевірила цей кейс і як визначила правильний lastmod для локалізованих сторінок.

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

Під час роботи над sitemap у Laravel я помітила неочевидну проблему: public page могла реально змінитися, але її lastmod залишався старим.

Причина була в структурі ICanUp. Один Post має спільний slug, але окремі public URLs для української та англійської версій, а основний контент цих версій зберігається в locale-specific content blocks.

Тому звичайний posts.updated_at не завжди достатньо точно описував останню видиму зміну конкретного URL. У результаті я перестала сприймати lastmod як просто timestamp Eloquent model і почала визначати його як останню реальну зміну public page.

Один Post у нас має спільний slug, але окремий public URL для кожної locale. Українська та англійська версії можуть змінюватися незалежно, тому одного спільного updated_at для multilingual sitemap може бути недостатньо.

Спочатку lastmod виглядав дуже просто

Найочевидніший варіант для Eloquent model - просто взяти його `updated_at`.

PHP - найпростіший sitemap lastmod
'lastmod' => $post->updated_at?->toAtomString(),

Для простої сторінки, де весь public content змінюється разом із одним database row, цього може бути достатньо.

Але в ICanUp Post має власні поля, translations і окремі content blocks для кожної locale.

Тому я поставила просте питання: якщо я редагую тільки English content block, чи повинна українська сторінка теж отримати новий lastmod?

Відповідь для такого локального content update - ні.

Один Post і один slug, але окремі locale URLs

Один Post - окремі locale URLs
Post #42slug: my-post UK→ https://example.com/posts/my-post EN→ https://example.com/en/posts/my-post

На рівні database це один Post зі спільним slug.

Але для search engine українська та англійська версії мають окремі public URLs і окремий localized content. English content може змінитися сьогодні, тоді як українська версія залишиться без змін.

Тут я зрозуміла, що для locale-specific content lastmod теж повинен враховувати конкретну locale.

Один Eloquent model не завжди означає одну однаково змінену public page.

Видимий контент живе не тільки в parent row

У моєму випадку основний текст Post і Page складається з content blocks.

Кожен block має свою locale, свій active state і власний updated_at. Тобто зміна English block може бути новішою за posts.updated_at, хоча український content при цьому не змінювався.

Саме тому я не могла визначати lastmod лише за parent model.

До

Що показує parent model

posts.updated_at
2026-08-01 10:00

Після

Що реально змінилося

EN active content block
updated_at: 2026-08-10 15:30

UK content
unchanged

Якщо видимий content зберігається в related tables, parent.updated_at не варто автоматично вважати єдиною датою останньої зміни public page.

Для content blocks мені був потрібен lastmod по locale

Для Post і Page я почала окремо визначати найновіший active content block для кожної доступної locale.

Locale-specific content timestamp
UK URL→ latest active UK content block updated_at EN URL→ latest active EN content block updated_at

При цьому locale-specific content timestamp - не єдине джерело lastmod.

Є також зміни самої entity та publication time. Якщо змінився сам Post, наприклад його shared slug або інше спільне public state, такий timestamp природно може стосуватися всіх його locale URLs.

А от зміна лише English content block не повинна штучно робити новішим UK URL.

Я зібрала максимальний timestamp для кожної locale прямо в query

Завантажувати всі content blocks і шукати найновіший у PHP не було потрібно. Я додала окремий `withMax()` aggregate для кожної доступної locale.

PHP - Locale-specific content lastmod aggregates
foreach ($this->localeSettings->available_locales as $locale) {    $attribute = $this->lastmodService        ->contentUpdatedAtAttribute($locale);     $query->withMax(        [            "contentBlocks as {$attribute}" => static fn (                Builder $contentBlocks            ): Builder => $contentBlocks                ->where('is_active', true)                ->where('locale', $locale),        ],        'updated_at',    );}
Aggregate одразу фільтрується і за locale, і за is_active. Sitemap цікавить timestamp того content, який реально бере участь у public page.

Inactive content не повинен змінювати lastmod

Під час перевірки я спеціально створила content block із timestamp, новішим за всі інші, але зробила його inactive.

Database знає про цю зміну, але користувач її не бачить.

Тому такий block не повинен робити public URL новішим у sitemap.

Active та inactive content
EN active blockupdated_at: 2026-08-10 15:30→ visible→ affects EN lastmod UK inactive blockupdated_at: 2026-08-12 18:00→ not visible→ must NOT affect UK lastmod

lastmod має описувати зміну public page, а не просто найновіший timestamp, який можна знайти в database.

Locale content - лише одне з джерел lastmod

У фінальній реалізації я не замінила posts.updated_at на content block timestamp.

SitemapLastmodService порівнює три можливі дати:

  1. updated_at самої entity

  2. її published_at

  3. і найновіший active content timestamp для конкретної locale.

У sitemap потрапляє найновіша з них.

PHP - Джерела фінального lastmod
foreach ([    $model->updated_at,    $model->getAttribute('published_at'),    $model->getAttribute(        $this->contentUpdatedAtAttribute($locale)    ),] as $value) {    // Keep the latest timestamp.}
Для мене правильним виявився не один «головний timestamp», а правило вибору найновішої дати серед усіх змін, які реально можуть змінити конкретну public page.

published_at теж може бути найновішою датою

Ще один case я окремо закріпила тестами: publication time може бути новішим за updated_at.

Наприклад, сторінку підготували раніше, але опублікували пізніше. У такому випадку саме published_at коректніше описує момент, коли public URL фактично став актуальним.

Тому published_at також бере участь у виборі lastmod для Post і Page.

Publication time як lastmod
updated_at2026-08-01 10:00 published_at2026-08-15 12:00 → lastmod = published_at

Найважливіший locale case я перевірила через реальний sitemap

Я не хотіла залишати цю поведінку лише припущенням.

У test case я створила Post зі старішим updated_at, потім додала новіший active English content block і ще новіший, але inactive Ukrainian block.

Після цього отримала реальний sitemap XML і порівняла lastmod окремо для UK та EN URL.

Дані контрольного test case
posts.updated_at→ 2026-08-01 10:00 EN active content→ 2026-08-10 15:30 UK inactive content→ 2026-08-12 18:00
Очікуваний locale-specific результат
UK URL→ inactive UK block ignored→ lastmod = posts.updated_at EN URL→ active EN block is newer→ lastmod = EN content updated_at
Test підтвердив саме потрібну поведінку: active English content оновлював lastmod English URL, а ще новіший inactive Ukrainian block не впливав на український URL.

Я перевіряла фінальний XML, а не тільки helper

Для мене було важливо перевірити весь contract: database query, locale aggregate, `SitemapLastmodService`, URL generation і готовий `<lastmod>` у sitemap response.

Приклад sitemap URL з lastmod
<url>    <loc>https://example.com/en/posts/my-post</loc>    <lastmod>2026-08-10T15:30:00+03:00</lastmod></url>

Такий test краще захищає реальну SEO-поведінку, ніж окрема unit-перевірка методу, який просто повертає дату.

Translations і content timestamps - це не одне й те саме

У ICanUp locale визначає, яку мовну версію бачить користувач, але сам lastmod я не будую на timestamp translation row.

Для Post і Page важливішим джерелом реальної зміни основного тексту стали locale-specific content blocks.

Translations залишаються частиною public page і визначають доступність та localized fields, але sitemap lastmod не варто автоматично прив'язувати до будь-якої related table лише тому, що вона містить locale.

Shared зміни й locale-specific зміни працюють по-різному

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

Якщо змінюється shared state самого Post, його updated_at може бути правильним сигналом для всіх locale URLs.

А якщо змінюється тільки active English content block, цей timestamp має вплинути саме на English URL.

Тому фінальний lastmod поєднує shared timestamps і locale-specific timestamp замість того, щоб вибирати лише один із цих підходів.

До

Shared Post change

posts.updated_at changed

UK URL → may receive new lastmod
EN URL → may receive new lastmod

Після

EN content-only change

EN active content updated

UK URL → unchanged by this block
EN URL → receives newer lastmod

Не кожній entity потрібна така складна логіка

Я не хотіла перетворювати sitemap у набір спеціальних умов для кожної моделі.

Для Category, де в цій реалізації lastmod достатньо описує сама entity, SitemapLastmodService просто використовує categories.updated_at.

Складніша locale-aware логіка залишилася для Post і Page, де public content має окремі content blocks.

Структура public content

Джерело lastmod

Проста entity

updated_at

Post / Page

max із updated_at, published_at,

locale content timestamp

Active locale content

враховується

Inactive content

ігнорується

Content іншої locale

не впливає на locale-specific aggregate

IndexNow і sitemap відповіли на дві частини одного питання

Під час роботи над IndexNow я вже прийшла до правила: після mutation потрібно визначити, які public URLs реально були affected.

Sitemap додав до цього ще одну вісь - time.

Тепер у SEO infrastructure я думаю не тільки яка entity змінилася, а який public URL змінився і яка дата найкраще описує останню видиму зміну цієї сторінки.

Першу частину цієї історії я детальніше розбирала в статті про Laravel IndexNow і автоматичну відправку нових та оновлених сторінок у Bing.

Що я тепер перевіряю для sitemap lastmod

  • Чи справді entity.updated_at описує всю public page.

  • Чи є visible content у related tables.

  • Чи може content кожної locale змінюватися незалежно.

  • Чи враховується лише active content.

  • Чи зміна однієї locale випадково не впливає на іншу.

  • Чи потрібно враховувати published_at.

  • Чи фінальний test перевіряє реальний sitemap XML.

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

  • lastmod - це не автоматично model.updated_at.

  • Shared slug не означає, що localized content змінюється одночасно.

  • Для multilingual content частина lastmod може бути locale-specific.

  • Inactive content не повинен робити URL «новішим».

  • published_at теж може бути найновішою релевантною датою.

  • Shared entity changes і locale content changes потрібно враховувати разом.

  • SEO behavior краще доводити через фінальний sitemap response.

Висновок

На початку sitemap lastmod здавався мені простим полем: взяти updated_at і додати його в XML.

Але multilingual content швидко показав слабке місце такого підходу. Один Post зі спільним slug може мати кілька locale URLs, а видимий content цих сторінок може змінюватися незалежно.

У фінальному рішенні я не відмовилася від parent timestamp. Я поєднала його з publication time та найновішим active content timestamp конкретної locale і вибираю найновішу релевантну дату.

Так lastmod краще відповідає реальному стану public page, а зміна однієї мовної версії не робить іншу штучно новішою.

Для sitemap мене цікавить не просто коли змінився database row, а коли реально змінилася сторінка за конкретним public URL.