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.
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`.
'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
Post #42slug: my-post UK→ https://example.com/posts/my-post EN→ https://example.com/en/posts/my-postAt 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:00What actually changed
EN active content block
updated_at: 2026-08-10 15:30
UK content
unchangedI 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.
UK URL→ latest active UK content block updated_at EN URL→ latest active EN content block updated_atThe 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.
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 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.
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 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:
the entity's own
updated_atits
published_atand the newest active content timestamp for the requested locale.
The newest of those dates becomes the sitemap lastmod.
foreach ([ $model->updated_at, $model->getAttribute('published_at'), $model->getAttribute( $this->contentUpdatedAtAttribute($locale) ),] as $value) { // Keep the latest timestamp.}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.
updated_at2026-08-01 10:00 published_at2026-08-15 12:00 → lastmod = published_atI 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.
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_atI 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.
<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 lastmodEN content-only change
EN active content updated
UK URL → unchanged by this block
EN URL → receives newer lastmodNot 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 |
|
|---|---|
Simple entity | updated_at |
Post / Page | max of 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.
Related Articles
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.



