Skip to content

feat(entries): add a localize action - #48

Merged
sylvesterdamgaard merged 2 commits into
cboxdk:mainfrom
Orange-Hotel-Marketing:feat/entries-localize
Sep 9, 2026
Merged

feat(entries): add a localize action#48
sylvesterdamgaard merged 2 commits into
cboxdk:mainfrom
Orange-Hotel-Marketing:feat/entries-localize

Conversation

@JorisOrangeStudio

Copy link
Copy Markdown
Contributor

Description

There is no way to translate an entry. create with another site makes an unrelated entry with its own id, not a localization, and update with a site the entry has no localization in correctly refuses. The localization had to be created by hand in the Control Panel first.

Adds a localize action taking collection, id, target site and optional data.

Type of Change

  • 🐛 Bug fix
  • ✨ New feature
  • 🔧 Tool enhancement
  • 📝 Documentation
  • ♻️ Refactoring

Related Issue

None — found on a multi-site install where the only route to a second locale turned out to be the Control Panel.

Testing

  • Tests pass (composer test)
  • Code quality checks pass (composer quality)
  • Tested manually with Statamic

tests/Feature/Routers/EntriesLocalizeTest.php — 8 cases: translated values, an empty localization falling back to the origin, a blueprint with a required slug, all three guards, a missing entry, and update editing the localization afterwards.

Manual testing on a real multi-site install is what caught the required-slug case: the first version failed with "The Slug field is required" on any blueprint using Statamic's default slug rules. The regression test added for it fails on the unfixed code and passes here.

Verified against main: all 8 fail there, all pass here.
Full gate green — pint, PHPStan level 9, 1114 tests / 5625 assertions.

Environment

  • Statamic: v6.31.0
  • Laravel: v13.30.1
  • PHP: 8.4.25

Checklist

  • My code follows the project style
  • I've added/updated tests if needed
  • Tool responses follow the standard format

Notes

Uses Statamic's makeLocalization() rather than assembling an entry, so the origin is set — untranslated fields keep falling back — and a structured collection places the localization in the target site's tree in the same position as the origin.

The localization's own slug is injected into the validated payload: slug is an entry property rather than a data key, and Statamic's default blueprint marks it required, so without it every localize fails (cf. #39). The {id} replacement excludes the entry from UniqueEntryValue, which the origin would otherwise trip.

data is optional: an empty localization inheriting everything is the normal starting point for a translator, and update with that site edits it afterwards.

localize is added to the write-action lists in EnforcesResourcePolicy and RouterHelpers, so it maps to entries:write rather than falling through to a read scope, and it requires the same Statamic permission as create.

DevelopmentTOS and others added 2 commits September 8, 2026 14:08
Translating a page had no route through the MCP. create with another site
makes an unrelated entry with its own id rather than a localization, and
update with a site the entry has no localization in correctly refuses, so
the localization had to be made in the Control Panel first.

localize uses Statamic's makeLocalization(), so the new entry keeps its
origin and a structured collection places it in the target site's tree.
data is optional — an empty localization inheriting everything is the
normal starting point. The localization's own slug is injected into the
validated payload, since Statamic's default blueprint marks slug required
and it is an entry property rather than a data key (cf. cboxdk#39). Guards cover
the collection not being available in the target site, the target being the
origin site, and a localization that already exists. Added to the
write-action lists so it maps to entries:write rather than defaulting to a
read scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
addValues() populates every field in the blueprint, so taking all of
values() wrote an explicit null for each field the translator left alone.
Two things broke.

The localization stopped falling back to its origin: a stored null is a
value, not an absence, so every untranslated field read back as empty
instead of inheriting.

And the next update to that localization failed on fields the caller never
sent. update validates the stored data merged with the incoming payload, so
those nulls reached rules the fields would otherwise have skipped —
"seo_canonical_entry: The Entry field must be an array", "seo_canonical_custom:
The URL field must be a valid URL". The only way past it was to send a valid
value for each unrelated field.

Intersect the processed values with the payload, so a localize stores what
it was given and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sylvesterdamgaard

sylvesterdamgaard commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Reviewed and approved on the substance — this just needs a rebase on main before I can merge it.

EntriesRouter picked up the merge_sets work from #47, which lands adjacent to localizeEntry. Textual conflict only. I can't push to the branch (maintainerCanModify is off), so a rebase from your side is the quickest path — ping me and I'll merge.

This was the one in the batch I looked at hardest, because a new write action is exactly where an authorization gap would hide. It's gated correctly:

  • Adding localize to isWriteAction() in EnforcesResourcePolicy and to the write list in RouterHelpers::getRequiredTokenScope() is what makes it require entries:write and a write-mode resource policy check. Omitting either would have left a write action passing read-level gates — the two one-line changes are the security-relevant part of this PR, and they're right.
  • getRequiredPermissions() maps it to create {collection} entries, which is the correct Statamic permission for creating a localization.

On the implementation, the part I'd have most expected to get wrong is handled: taking array_intersect_key($processed, $data) rather than all of values() avoids writing an explicit null for every untouched field, which would otherwise defeat the origin fallback — the whole point of a localization — and then poison later updates, since update validates stored data merged with incoming. Good catch, and the comment explains it well enough that nobody will "simplify" it later.

Also verified the guards do what they say: makeLocalization() for correct origin and tree placement, rejection when the entry already originates in that site or already has a localization there, and the collection/site availability check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants