0% прочитано

Laravel Sitemap lastmod with Translations: When updated_at Is Not the Real Page Update

While working on my sitemap, I found a subtle problem: a public page could change through its translation table while the parent model updated_at stayed unchanged. This article shows how I verified the case and chose the correct lastmod for localized pages.

2 вересня 2026 р. 6 хв читанняLaravel

While working on my Laravel sitemap, I noticed a subtle problem: a public page could change while its lastmod remained old.

The reason was the structure of ICanUp. A Post has one shared slug but separate public URLs for the Ukrainian and English versions, while the main content for those versions is stored in locale-specific content blocks.

That meant posts.updated_at alone did not always describe the latest visible change to a specific URL accurately enough. I eventually stopped treating lastmod as just an Eloquent model timestamp and started treating it as the latest real change to the public page.

A Post in my application has one shared slug but a separate public URL for each locale. The Ukrainian and English versions can change independently, so one shared updated_at may not be enough for a multilingual sitemap.

This case naturally follows my article about Laravel IndexNow and automatically submitting changed URLs to Bing: there the main question was which public URLs changed; here it is which modification date should be exposed for each of them.

At First, lastmod Looked Very Simple

The most obvious option for an Eloquent model is simply to use its `updated_at`.

PHP - The simplest sitemap lastmod
'lastmod' => $post->updated_at?->toAtomString(),

For a simple page where all public content changes together with one database row, that may be enough.

In ICanUp, however, a Post has its own fields, translations, and separate content blocks for each locale.

That led me to a simple question: if I edit only an English content block, should the Ukrainian page also receive a new lastmod?

For that locale-specific content change, the answer is no.

One Post and One Slug but Separate Locale URLs

One Post - separate locale URLs
Post #42slug: my-post UK→ https://example.com/posts/my-post EN→ https://example.com/en/posts/my-post

At the database level, this is one Post with one shared slug.

For a search engine, however, the Ukrainian and English versions have separate public URLs and separate localized content. The English content may change today while the Ukrainian version remains untouched.

That was when I realized the locale-specific content part of lastmod also had to respect the specific locale.

One Eloquent model does not always mean one identically updated public page.

Visible Content Lives Outside the Parent Row Too

In my case, the main body of a Post or Page is composed from content blocks.

Each block has its own locale, active state, and updated_at. An English block can therefore be newer than posts.updated_at while the Ukrainian content remains unchanged.

That is why I could not calculate lastmod from the parent model alone.

До

What the parent model shows

posts.updated_at
2026-08-01 10:00

Після

What actually changed

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

UK content
unchanged

When visible content is stored in related tables, parent.updated_at should not automatically be treated as the only modification date for the public page.

I Needed a Per-Locale Timestamp for Content Blocks

For Posts and Pages, I started calculating the newest active content-block timestamp separately for every available locale.

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

The locale-specific content timestamp is not the only source of lastmod.

Entity-level changes and publication time still matter. If the Post itself changes - for example, its shared slug or another piece of shared public state - that timestamp can naturally apply to all locale URLs.

A change to only an English content block, however, should not make the UK URL look newer.

I Calculated the Maximum Timestamp for Each Locale in the Query

There was no reason to load every content block and find the newest one in PHP. I added a separate `withMax()` aggregate for each available 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',    );}
The aggregate is filtered by both locale and is_active. The sitemap needs the timestamp of content that actually participates in the public page.

Inactive Content Must Not Change lastmod

For the verification case, I deliberately created another content block with a timestamp newer than everything else but marked it inactive.

The database knows about that change, but the user cannot see it.

That block therefore should not make the public URL appear newer in the sitemap.

Active and 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 should describe a change to the public page, not simply the newest timestamp available in the database.

Locale Content Is Only One Source of lastmod

In the final implementation, I did not replace posts.updated_at with the content-block timestamp.

SitemapLastmodService compares three possible dates:

  1. the entity's own updated_at

  2. its published_at

  3. and the newest active content timestamp for the requested locale.

The newest of those dates becomes the sitemap lastmod.

PHP - Sources of the final lastmod
foreach ([    $model->updated_at,    $model->getAttribute('published_at'),    $model->getAttribute(        $this->contentUpdatedAtAttribute($locale)    ),] as $value) {    // Keep the latest timestamp.}
For my case, the correct solution was not one “main timestamp”, but a rule that selects the newest date from every change that can genuinely affect the specific public page.

published_at Can Be the Newest Date Too

I also covered another case explicitly in tests: publication time can be newer than updated_at.

A page may have been prepared earlier and published later. In that situation, published_at better represents the moment when the public URL actually became current.

That is why published_at also participates in lastmod calculation for Posts and Pages.

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

I Verified the Important Locale Case Through the Real Sitemap

I did not want this behavior to remain an assumption.

In the test case, I created a Post with an older updated_at, then added newer active English content and an even newer but inactive Ukrainian block.

After that, I requested the real sitemap XML and compared lastmod separately for the UK and EN URLs.

Controlled test data
posts.updated_at→ 2026-08-01 10:00 EN active content→ 2026-08-10 15:30 UK inactive content→ 2026-08-12 18:00
Expected locale-specific result
UK URL→ inactive UK block ignored→ lastmod = posts.updated_at EN URL→ active EN block is newer→ lastmod = EN content updated_at
The test confirmed exactly the behavior I wanted: active English content updated the English URL's lastmod, while the even newer inactive Ukrainian block did not affect the Ukrainian URL.

I Tested the Final XML, Not Just a Helper

I wanted the test to cover the complete contract: database query, locale aggregate, `SitemapLastmodService`, URL generation, and the generated `<lastmod>` in the sitemap response.

Example sitemap URL with lastmod
<url>    <loc>https://example.com/en/posts/my-post</loc>    <lastmod>2026-08-10T15:30:00+03:00</lastmod></url>

That protects the real SEO behavior better than testing only an internal method that returns a timestamp.

Translations and Content Timestamps Are Not the Same Thing

In ICanUp, the locale determines which language version the user sees, but I do not build lastmod from a translation-row timestamp.

For Posts and Pages, locale-specific content blocks became the more useful source for detecting changes to the main visible content.

Translations remain part of the public page and determine localized fields and availability, but sitemap lastmod should not automatically depend on any related table simply because it contains a locale.

Shared Changes and Locale-Specific Changes Behave Differently

This became the most important part of the implementation for me.

If shared state on the Post changes, its updated_at can be the correct signal for all locale URLs.

If only an active English content block changes, that timestamp should affect the English URL specifically.

The final lastmod therefore combines shared timestamps with a locale-specific timestamp instead of choosing only one of those approaches.

До

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

Not Every Entity Needs This Complexity

I did not want the sitemap to become a collection of special cases for every model.

For Category, where the entity timestamp is sufficient in this implementation, SitemapLastmodService simply uses category.updated_at.

The more complex locale-aware behavior remains limited to Posts and Pages, where public content has separate content blocks.

Public content structure

lastmod source

Simple entity

updated_at

Post / Page

max of updated_at, published_at,

locale content timestamp

Active locale content

included

Inactive content

ignored

Content from another locale

does not affect the locale-specific aggregate

IndexNow and Sitemap Answered Two Parts of the Same Question

While building IndexNow, I had already arrived at one rule: after a mutation, determine which public URLs were actually affected.

The sitemap added another dimension to that rule - time.

For SEO infrastructure, I now think not only which entity changed? but also which public URL changed, and which date best represents the latest visible change to that page?.

I covered the first half of that story in more detail in my article about Laravel IndexNow and automatically submitting new and updated pages to Bing.

What I Now Check for Sitemap lastmod

  • Does entity.updated_at really describe the complete public page?

  • Is visible content stored in related tables?

  • Can content for each locale change independently?

  • Does the calculation include only active content?

  • Can one locale accidentally affect another locale's timestamp?

  • Should published_at also be considered?

  • Does the final test verify the real sitemap XML?

What I Kept for Next Time

  • lastmod is not automatically model.updated_at.

  • A shared slug does not mean localized content changes at the same time.

  • For multilingual content, part of lastmod can be locale-specific.

  • Inactive content should not make a URL look newer.

  • published_at can also be the newest relevant date.

  • Shared entity changes and locale content changes need to be considered together.

  • SEO behavior is best proven through the final sitemap response.

Conclusion

At first, sitemap lastmod looked like a simple field to me: take updated_at and put it into the XML.

Multilingual content quickly exposed the weakness in that assumption. A single Post with one shared slug can have multiple locale URLs, while the visible content behind those pages can change independently.

In the final solution, I did not discard the parent timestamp. I combined it with publication time and the newest active content timestamp for the requested locale, then selected the newest relevant date.

That makes lastmod better reflect the actual state of the public page without making another language version look artificially fresh.

For a sitemap, I care less about when a database row changed and more about when the page behind a specific public URL actually changed.