Problem/Motivation
Reads currently negotiate their language from the Accept-Language request header. Carrying language state in a header makes every translatable response effectively uncacheable at every layer:
- The internal (anonymous) page cache keys responses by URL only and ignores request headers (core #2430335, open since 2015), so the module must trigger a page-cache kill switch unconditionally on every translatable read. Anonymous JSON:API traffic gets zero internal page caching.
- The dynamic page cache varies by the
headers:Accept-Languagecache context, whose cardinality is unbounded: browsers send highly variable values (en-US,en;q=0.9,fr-FR,fr;q=0.9,en;q=0.8, ...), one cache entry each. Vary: Accept-Languagefragments every Vary-honouring reverse proxy / CDN cache per browser language string.
Worse, the page cache stores 4xx responses by URL for cache_ttl_4xx (an hour by default), ignoring headers and cache contexts, so any header-triggered error on an otherwise-good URL is a cache-poisoning vector: one stray header poisons that URL's cached response for every later anonymous client.
Separately, Accept-Language puts the fallback policy in the client's hands (an ordered accept-list), while Drupal already has a site-owned fallback policy: the language fallback candidates system, which modules such as language_hierarchy configure, and which the rendered HTML site already follows. The API and the rendered site should fall back identically.
Steps to reproduce
- Install the module on a multilingual site with the internal page cache enabled and inspect any translatable JSON:API read: it is never stored (
X-Drupal-Cache: UNCACHEABLE (response policy)), because the kill switch must fire on every translatable read to keep the URL-keyed cache from serving one language's body to another language's request. - Behind a Vary-honouring proxy, watch the dynamic page cache and CDN fragment per distinct browser
Accept-Languagestring for the same URL.
Proposed resolution
Move all read-language state into the URL and defer fallback to the backend:
- Reads select their language exclusively with the existing
langCodequery parameter. Strict semantics are unchanged: a missing translation is a404(individual) or an exclusion (collections,/related/, menu items, withmeta.omittedreporting). The strict 404 becomes cacheable, carries the missed entity's cacheability (creating the translation invalidates the cached miss through normal tag invalidation), and advertises the viewable translations in the errordetailand asmeta.availableTranslations(taken over from the removed 406). - A new
includeFallback=1query parameter (reads only, requireslangCode, only the literal value1so cache variants stay canonical) resolves the best available translation through the site's language fallback chain: core'sLanguageManager::getFallbackCandidates()as applied byEntityRepository::getTranslationFromContext(). The module implements no fallback policy of its own, sohook_language_fallback_candidates_alter()implementers (language_hierarchy) compose automatically, and the API falls back identically to the rendered site. The entity's default translation is the terminal candidate, so an individual fallback read of an existing entity always returns200; a fallback collection includes every viewable item, each in its chain-resolved language. Config entities serve override-or-base through the config override layer (where language_hierarchy's chained override provider applies transparently). Fallback-mode responses carry theconfig:configurable_language_listcache tag, so rewiring the chain invalidates cached fallback bodies. - Language request headers become inert on reads,
Content-Languageexactly likeAccept-Language: no read outcome (a selection or a rejection alike) may depend on a request header, per the poisoning vector above. A read carrying only language headers gets core's default-language behaviour, exactly as stock core JSON:API.Content-Languagekeeps its selecting and validating role on writes, which are never page-cached. - Removed outright: the strict
Accept-Languagenegotiation and its406 Not Acceptablepath, theContent-Locationcanonicalization, the site-wideVaryhandling, and the page-cache kill switch. Responses declare their language variance with theurl.query_args:langCodeandurl.query_args:includeFallbackcache contexts, declared unconditionally on translatable responses; cache cardinality is bounded at (configured languages + 1) × 2 variants per URL, and every read is cacheable by URL at every layer, CDNs included. - The
jsonapi_menu_itemscompanion route follows the same two-mode contract, and the optional OpenAPI submodule documents the two query parameters instead of the read header and the 406.
Implementation notes:
- The
meta.availableTranslationserror injection runs at response priority 100, before the dynamic page cache stores the body (7), so cached 404s carry the meta. - The unknown-
langCode422 is cacheable and carries theconfig:configurable_language_listtag, so configuring the language un-caches the rejection. - Test coverage includes a test module mirroring language_hierarchy's exact storage and hook (a
fallback_langcodethird-party setting onlanguage.entity.*applied viahook_language_fallback_candidates_alter()), and a positive internal-page-cache suite replacing the old poisoning regression test: variants cached per URL, cached strict 404s invalidated by translation creation, cached fallback bodies invalidated by translation creation and chain rewires.
Remaining tasks
- Review the merge request.
- Retire the now-obsolete sibling issues built on the header contract (strict no-fallback negotiation, Vary scoping, collection Accept-Language cache context) in favour of this one.
- Propose the URL-keyed contract on core #3199697: the caching argument (the page cache is URL-keyed and #2430335 is over a decade old) applies to core's planned
Accept-Languageread negotiation identically.
API changes
- BREAKING for clients that negotiated reads via
Accept-Language: the header is now ignored on reads (a header-only read returns the default translation with200, exactly like stock core JSON:API, instead of a negotiated translation or a406). Migration: put the language in the URL,?langCode=<lang>for the strict read, plus&includeFallback=1to defer fallback to the site's chain. - New
includeFallbackquery parameter on translatable reads (individual, collection,/related/, menu items). - The
406 Not Acceptablestatus and theContent-Location/Vary: Accept-Languageresponse headers are removed; the strict404gainsmeta.availableTranslations. - A
Content-Languageheader on a cacheable request is no longer rejected with400; it is ignored. Writes are unchanged.
Issue fork jsonapi_multilingual-3613823
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
lauriiiComment #3
lauriiiComment #6
lauriii