Field policy
legacy and all policies, the shared-field denylist, the dry run and the back-fill.
On this page
When locales are enabled, something has to decide which fields are stored per locale and which are shared across all of them. That decision is the field policy, and Systhema ships two.
localization.policy | What is localized | Default |
|---|---|---|
'legacy' | Fields whose NAME appears on a fixed allowlist (title, slug, content, label, url, …). | Yes, until 2.0 |
'all' | Every field of every Systhema-owned schema, except the documented exceptions below. | No |
A project with no locales is unaffected by either. Nothing on this page applies to a single-language site.
Why 'all' existsLink to this section
The legacy policy matched bare field names. That conflated a field's identity with the intent behind it, and produced results nobody chose:
forms.emailsstored the message per locale and the recipient shared. Within one notification rule, what you say was per-locale and who hears it was not. "Arabic enquiries to one inbox, everything else to another" was inexpressible, and the admin gave the editor no hint of it. A null or wrong recipient is silent — the form still stores the submission and the visitor still sees the thank-you page.pages.meta.notListedwas shared, sonoindexwas all-or-nothing across every locale. Indexing English while holding another market back is a normal requirement.- Select option labels could not be expressed at all, so they needed bespoke code to inject a localized
labelbeside the sharedvalue. - A field called
urlwas localized wherever it appeared, in any block, whether or not that made sense there; a field calledemailTonever was. Nobody decided that — it is the residue of which names happened to reach aSet.
Under 'all' the default is "localized", every exception is a path with a written reason, and the exceptions live in one auditable list (SYSTHEMA_SHARED_FIELDS in packages/payload/src/config/localizationPolicy.ts).
Enabling itLink to this section
export default withSysthema(config, {
locales: systhemaConfig.locales,
localization: {
policy: 'all',
// Optional per-project exceptions, appended to the built-in lists.
shared: ['pages.default.hero.aspectRatio'],
sharedRows: ['pages.default.gallery.items'],
localized: ['pages.myTemplate.strapline'],
},
})The same options can live in systhema.config.ts, next to the locale contract, so they are declared once:
packages: {
payload: {
enabled: true,
localization: { policy: 'all' },
},
},The plugin option wins on policy; the shared / sharedRows / localized lists from both sources are appended, never replaced.
Ownership is still the outer gateLink to this section
The inversion only reaches schemas Systhema authored. It never infers localization for:
- consumer collections and globals (
customCollections/customGlobals); - consumer-authored page/post template fields.
Built-in templates mark their own fields, so everything Systhema ships is covered. A consumer template field is opted in by path:
localization: { policy: 'all', localized: ['pages.caseStudy.strapline'] }An explicit localized: false on a field always wins, over the denylist and over localized alike.
Arrays and blocks: which half is per-localeLink to this section
An array or blocks field has two halves, and localizing it means picking one. They are different features with different migrations, so the choice is explicit per container.
Localized rows — the default. The container carries localized: true; each locale gets its own rows, in its own order, of its own block types. Its leaves are then NOT localized, because Payload forbids a localized field inside a localized one. This is what translating a page body or a navigation actually needs.
Shared rows — sharedRows. The container stays shared and its leaves are localized: one row set, per-locale values. Correct wherever the rows are a schema, a key set, or a list of facts rather than content.
Neither is free:
- A localized-rows container inherits its fallback locale's rows including their ids the first time an editor opens an untranslated locale. Saving them raises
ValidationError: id — Value must be unique, and the editor has to re-create the rows for that locale. This predates the inverted policy —header.navigationandfooter.mainhave always behaved this way — but'all'makes more containers behave like it. The back-fill migration below avoids it for existing content by copying rows with fresh ids. - A shared-rows container cannot express "this market lists four services, that one lists six".
If the schema already declares localized: true on a leaf inside a container, the policy reads that as the author choosing the leaves-localized model and keeps the rows shared, rather than producing an invalid nested-localized schema out of two decisions that each looked fine on their own.
What stays shared, and whyLink to this section
The bar for an entry is "localizing this is wrong", not "localizing this is inconvenient". A field that merely costs an editor a second edit per locale is not an entry — that is the trade 'all' deliberately makes. A project that disagrees adds its own path to shared.
Submission keysLink to this section
| Path | Reason |
|---|---|
forms.fields.*.name | The submission key. Localizing it stores one answer under a different column per domain and scatters a single question across a CSV export. |
forms.fields.*.options.value | The stored answer. label beside it carries the translation. |
forms.fields.payment.priceConditions.fieldToUse | Names another form field by its shared name; a per-locale value would dangle. |
forms.fields.payment.paymentProcessor | A payment-integration identifier, not text a visitor reads. |
forms.fields.upload.uploadCollection | A Payload collection slug — a schema reference. |
forms.fields.upload.mimeTypes.mimeType | A protocol identifier from the IANA registry, not language. |
Upload identityLink to this section
| Path | Reason |
|---|---|
uploads.uploadedBy | Provenance of the binary. One file, uploaded once, by one person. |
uploads.filename | One binary, one set of facts about it. |
uploads.mimeType | One binary, one set of facts about it. |
uploads.filesize | One binary, one set of facts about it. |
uploads.width | One binary, one set of facts about it. |
uploads.height | One binary, one set of facts about it. |
Access controlLink to this section
| Path | Reason |
|---|---|
users.roles | Authorization. A per-locale role means an editor who is an admin on one domain and nothing on another, decided by whichever locale the request carried. |
users.capabilities | Authorization, for the same reason. |
users.avatar | An account attribute of a person, not site content addressed at a market. |
Structural keys Systhema itself readsLink to this section
| Path | Reason |
|---|---|
pages.parent | The nested-docs edge. A per-locale parent would give a page different ancestors — and different breadcrumbs — depending on the request. |
pages.pageTemplate | Selects WHICH schema the rest of the document is stored under. A per-locale template makes a document two shapes at once. |
posts.postTemplate | The same. |
posts.postType | A classification key the archive filters and queries on. Localizing it drops a post out of its own listing in some locales. |
**.slugLock | A UI latch meaning "re-derive the slug when the title changes". Localizing it drops the shared column from pages and _pages_v — destructive, for nothing. |
**.keyLock | The slugLock of a form field, latching the shared name. |
**._tier / **._inCard | Written by the tier detector to record where a block sits in the editor nesting. Structure, not language. |
Cookie-consent inventoryLink to this section
The consent table is a legal declaration of what the site sets in a visitor's browser. Its prose is translated; its facts are not. general-settings.cookieConsent.cookies keeps shared rows (a locale must not be able to declare a different inventory from the code that runs on every domain), and these stay shared inside them — description and duration are localized.
| Path | Reason |
|---|---|
general-settings.cookieConsent.cookies.name | The cookie name a browser actually stores. |
general-settings.cookieConsent.cookies.domain | The domain the cookie is set on. |
general-settings.cookieConsent.cookies.category | The consent category gating the cookie. |
general-settings.cookieConsent.cookies.party | First- or third-party is a fact, not a description. |
Infrastructure and plumbingLink to this section
| Path | Reason |
|---|---|
cloudflare.autoPurge | A site-wide CDN switch, and the only persisted field on the Cloudflare global. Its self-saving POST and its runtime lookup both carry no locale, so a per-locale value would leave them reading the default while other locales held stale toggles. |
general-settings.__seededAt | Records whether the cookie-consent defaults have been seeded for this INSTALL. A per-locale marker would re-seed the same global once per locale. |
Records of eventsLink to this section
form-submissions.** and systhema-ai-usage.** are excluded wholesale. A submission is one event that happened once, in one language; a usage ledger row records one generation. There is no translation of a fact.
The form submit buttonLink to this section
forms.submitButtonTitle is the button a visitor clicks. It is localized under 'all' and has never been localized under 'legacy': the legacy allowlist names submitButtonLabel, which applySysthemaFormDefaults deletes before the policy runs, while buildSubmitButtonFields splices in a differently-named field. The dead entry has been removed rather than repointed, because adding submitButtonTitle to the legacy set would move a column on the next deploy of a project that changed nothing.
A project staying on 'legacy' can fix just this one field:
localization: { policy: 'legacy', localized: ['forms.submitButtonTitle'] }localized is additive and works under either policy, so it never surprises a project that declared nothing. shared and sharedRows apply to 'all' only — under 'legacy', localized: false on the field is already the way to keep something shared.
Deliberately NOT on the listLink to this section
posts.publishedAt and posts.author are localized under 'all'. A translation can legitimately be published later than its source and credited to the translator. If that is wrong for your project, add them to shared.
The dry runLink to this section
Before changing anything, print exactly what would move:
pnpm payload systhema-localization-report # human-readable
pnpm payload systhema-localization-report --json # machine-readable
pnpm payload systhema-localization-report --no-countsIt reads the decisions out of the config your project actually loaded, rather than re-deriving the policy, so it can never describe a schema the site does not run. It writes nothing.
Output from a real five-locale project (ar, en, sk, cs, hu), running legacy, with the Posts and Redirects modules and cookie consent on:
Systhema content localization — dry run
active policy : legacy
locales : ar, en, sk, cs, hu
100 field(s) would change from SHARED to LOCALIZED:
footer
shared → localized footer.social (array)
general-settings 1 document(s) × 5 locale(s) = 5 rows to back-fill
shared → localized general-settings.cookieConsent.theme (radio)
shared → localized general-settings.pages.homepage (relationship)
shared → localized general-settings.posts.showAuthor (checkbox)
…
pages 42 document(s) × 5 locale(s) = 210 rows to back-fill
shared → localized pages.default.hero.heroType (radio)
shared → localized pages.listing.limit (number)
shared → localized pages.meta.notListed (checkbox)
…
posts 17 document(s) × 5 locale(s) = 85 rows to back-fill
shared → localized posts.author (relationship)
shared → localized posts.publishedAt (date)
shared → localized posts.meta.notListed (checkbox)
…
redirects 0 document(s) × 5 locale(s) = 0 rows to back-fill
shared → localized redirects.to.reference (relationship)
shared → localized redirects.type (select)
…
19 field(s) stay SHARED:
pages.parent (relationship)
The nested-docs edge. The document tree is one tree; a per-locale parent
would give a page different ancestors — and different breadcrumbs —
depending on the request.
…Migrating an existing siteLink to this section
There are four cases, and only one is dangerous.
| Case | What happens |
|---|---|
| New single-language project | The policy never runs. Nothing to do. |
| New multi-locale project | Set policy: 'all' before creating content. No data to move. |
| Existing single-language project turning locales on | Already a migration moment — see Localizing an existing Payload site. Choose 'all' while you are there. |
| Existing multi-locale project upgrading the package | The dangerous one. See below. |
An existing multi-locale project already has localesEnabled === true. If the policy widened on a package upgrade, columns would move to their _locales siblings on a routine pnpm update, with the consumer switching nothing on. So it does not: policy defaults to 'legacy', and the only thing an upgrade does is print a one-time notice naming the choice. systhema doctor reports the same thing as a localization-policy warning. Declaring policy: 'legacy' explicitly silences both — it is a decision, and the check treats it as one.
Both carry the decision inline — what moves, that a back-fill migration comes first, and pnpm payload systhema-localization-report to see the actual list — and then point here for the procedure. This page ships inside the installed package at node_modules/@systhemaui/payload/docs/localization-policy.md, so the path a warning prints resolves in the consumer's own project rather than in the Systhema repo.
Deriving the planLink to this section
Do not write the operation list by reading the policy. Diff two databases:
- Point the app at an empty database with the final configuration (
policy: 'all', every locale) and let it create the schema. That is the reference. - Diff the live database against it for every generated object, not just columns: tables, columns,
_locale/localediscriminators, indexes, constraints, foreign keys, sequences and enums.information_schemaon PostgreSQL andPRAGMAon SQLite are the adapter-specific inputs to that comparison, not the comparison itself — a column-only diff misses the constraints Payload expects to exist. - Every difference becomes an operation or a line of DDL.
A plan derived this way cannot drift from the policy, and it catches the structural columns no field list mentions. Keep the reviewed plan with the migration; it is the audit trail for a retry or a restore.
Keep add-and-copy strictly separate from the drop. The additive DDL plus the back-fill can ship in one deploy and the destructive cleanup in a later one, so both schema shapes coexist while the site is running and no request ever sees a half-migrated table. A code revert is a safe rollback only until localized writes begin; once editors start saving per-locale content, roll forward with a fix or restore a verified backup instead.
The back-fillLink to this section
Moving a shared column into its _locales sibling does not carry the data. If the existing value is not copied into every configured locale, the content silently becomes null — and null is silent by construction here: a form with no recipient still accepts the submission and still thanks the visitor.
backfillSysthemaLocalization is seedSysthemaLocalization with every operation defaulting to fillLocales: 'all': each configured locale is written the value the shared column already held.
import type { MigrateUpArgs } from '@payloadcms/db-postgres'
import { sql } from '@payloadcms/db-postgres'
import {
backfillSysthemaLocalization,
createPayloadPostgresLocalizationSqlClient,
} from '@systhemaui/payload'
import systhemaConfig from '../../systhema.config'
export async function up({ db }: MigrateUpArgs): Promise<void> {
// 1. The Payload-generated ADDITIVE SQL: create the new `_locales` columns.
// The legacy columns must still exist at this point.
const report = await backfillSysthemaLocalization(
createPayloadPostgresLocalizationSqlClient(db, sql),
'postgres',
{
operations: [
{
kind: 'localeTable',
sourceTable: 'forms_emails',
targetTable: 'forms_emails_locales',
columns: { email_to: 'email_to', cc: 'cc', bcc: 'bcc', reply_to: 'reply_to' },
// The target has a NOT NULL column outside this move, and not every
// form is translated into every locale.
missingParentRow: 'cloneDefaultLocale',
},
{
kind: 'localeTable',
sourceTable: 'pages',
targetTable: 'pages_locales',
columns: { meta_not_listed: 'meta_not_listed' },
},
// A localized-rows array: stamp the default locale on the existing rows
// and copy them once per remaining locale, with fresh ids.
{ kind: 'rowLocale', table: 'footer_social', fillLocales: 'all', childTables: [] },
],
},
{ locales: systhemaConfig.locales, acknowledgeDefaultLocale: 'en', postgresSchema: 'public' },
)
console.log('[systhema] back-filled per locale:', report.operations)
// 3. Constraints, then the DESTRUCTIVE cleanup that drops the legacy columns.
}Everything the existing-site migration guide says still applies: review the generated migration, split it into additive → seed → destructive, never run the unedited generated migration first, and never let a DROP share a schema diff with an unrelated batch of CREATEs.
Behaviour worth knowing:
- It fills the moved column into locale rows that already exist, not only into rows it creates. A translated site has a row in every locale already, with the freshly-created column null in each; an insert-only helper would leave every one of them null.
- A null in a moved column is "not filled yet", not a conflict. Only a value that is present and disagrees aborts the migration.
- It never overwrites a translation. A locale row that already exists and disagrees with the legacy value aborts the migration with the parent id, rather than replacing what someone wrote.
- It is idempotent and resumable. An interrupted run fills only the locales it had not reached.
dryRun: trueruns every schema and conflict check, reportsperLocalecounts, and writes nothing.- The locale discriminator is not always called
_locale. A_localessibling and an array/blocks row table use_locale; a_relstable useslocale, with no underscore. PasslocaleColumn: 'locale'on those operations — preflight now names the alternative when the table has it. cloneDefaultLocalemostly bites the VERSION tables, not the live ones. On a translated site every document already has a row in every locale, so cloning a live_localestable creates nothing. Historical versions are the opposite: they routinely lack rows for locales added later. Measured on a real five-locale site, one migration cloned 0 rows intopages_localesand 135 into_pages_v_locales. Nothing reads those rows — but restoring one of those versions in the admin writes the default locale's values, slug included, into that locale. Leave version tables out of acloneDefaultLocaleoperation unless a NOT NULL column forces it, and know what you are accepting if it does.- A NOT NULL column outside the move blocks creating a locale row. A parent with no row in a target locale needs one INSERTed, and an insert listing only the moved columns cannot satisfy an unrelated
NOT NULLcolumn —forms_locales.titleis the common one, since a required field that was already localized is NOT NULL there. Preflight refuses up front and names the columns. SetmissingParentRow: 'cloneDefaultLocale'on that operation to seed the row from the default locale first. Weigh it per table: cloning materializes the default locale's values into a locale that previously had no row, which afallbackLocale: falseread can see — right forforms_locales, worth thinking hard about for a table Systhema resolves routes against. - It is exercised against both adapters. The helper's SQL is adapter-specific — a correlated
UPDATE … COALESCE(… (SELECT …))against a schema-qualified target, and anINSERT INTO t … SELECT … FROM t— so it is tested against a real PostgreSQL server as well as SQLite (migrations/backfill.postgres.test.ts, which skips withoutSYSTHEMA_TEST_POSTGRES_URL). fillLocales: 'all'on arowLocaleoperation requireschildTables. Copying a parent row per locale orphans its children unless they are copied and re-pointed too, and no introspection can tell a child row table from an unrelated one with a_parent_id. Declare[]to assert there are none; a non-empty list is refused with instructions, not guessed at.seedSysthemaLocalizationis unchanged and still defaults to the default locale only, so migrations written before this release behave exactly as they did.
What it costsLink to this section
Measured against a real Payload SQLite instance, five locales (ar/en/sk/cs/hu), 120 pages with hero, SEO and listing content written in every locale — legacy versus all on the same seed:
| Measure | legacy | all | Change |
|---|---|---|---|
_locales tables | 9 | 9 | — |
Rows across all _locales | 2,520 | 2,520 | 0% |
pages_locales columns | 19 | 36 | +17 |
| Database file | 3,616 KiB | 3,828 KiB | +5.9% |
The row count does not change, and that is the headline. The intuition that five locales times every field means five times the rows is wrong for this transition: every Systhema schema already had at least one localized field, so its _locales table and its one row per document per locale existed the moment locales were switched on at all. The inversion adds columns to rows that already exist. What it costs is table width, not table height.
Read latency was measured over 1,000 list reads (limit: 10, depth: 1) and 1,000 single-document reads (depth: 2) per policy. The difference is not measurable above process-order noise on this dataset: across repeated runs the delta ranged from −81% to +149% and flipped sign when the two policies were run in the opposite order, while the structural numbers above were identical on every run. Take the honest reading — the extra columns did not produce a detectable read cost here — and re-measure on your own data before assuming either direction.
Reproduce it with packages/payload/scripts/benchmark-localization-policy.ts; the numbers above come from BENCH_PAGES=120 BENCH_READS=1000.