Під час роботи над sitemap у Laravel я помітила неочевидну проблему: public page могла реально змінитися, але її lastmod залишався старим.
Причина була в структурі ICanUp. Один Post має спільний slug, але окремі public URLs для української та англійської версій, а основний контент цих версій зберігається в locale-specific content blocks.
Тому звичайний posts.updated_at не завжди достатньо точно описував останню видиму зміну конкретного URL. У результаті я перестала сприймати lastmod як просто timestamp Eloquent model і почала визначати його як останню реальну зміну public page.
Спочатку lastmod виглядав дуже просто
Найочевидніший варіант для Eloquent model - просто взяти його `updated_at`.
'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 #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 blocks мені був потрібен lastmod по locale
Для Post і Page я почала окремо визначати найновіший active content block для кожної доступної locale.
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.
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', );}Inactive content не повинен змінювати lastmod
Під час перевірки я спеціально створила content block із timestamp, новішим за всі інші, але зробила його inactive.
Database знає про цю зміну, але користувач її не бачить.
Тому такий block не повинен робити public URL новішим у sitemap.
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 lastmodlastmod має описувати зміну public page, а не просто найновіший timestamp, який можна знайти в database.
Locale content - лише одне з джерел lastmod
У фінальній реалізації я не замінила posts.updated_at на content block timestamp.
SitemapLastmodService порівнює три можливі дати:
updated_atсамої entityїї
published_atі найновіший active content timestamp для конкретної locale.
У sitemap потрапляє найновіша з них.
foreach ([ $model->updated_at, $model->getAttribute('published_at'), $model->getAttribute( $this->contentUpdatedAtAttribute($locale) ),] as $value) { // Keep the latest timestamp.}published_at теж може бути найновішою датою
Ще один case я окремо закріпила тестами: publication time може бути новішим за updated_at.
Наприклад, сторінку підготували раніше, але опублікували пізніше. У такому випадку саме published_at коректніше описує момент, коли public URL фактично став актуальним.
Тому published_at також бере участь у виборі lastmod для Post і Page.
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.
posts.updated_at→ 2026-08-01 10:00 EN active content→ 2026-08-10 15:30 UK inactive content→ 2026-08-12 18:00UK URL→ inactive UK block ignored→ lastmod = posts.updated_at EN URL→ active EN block is newer→ lastmod = EN content updated_atЯ перевіряла фінальний XML, а не тільки helper
Для мене було важливо перевірити весь contract: database query, locale aggregate, `SitemapLastmodService`, URL generation і готовий `<lastmod>` у sitemap response.
<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 lastmodEN 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 | Джерело |
|---|---|
Проста entity | updated_at |
Post / Page | max із 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.



