This project is not covered by Drupal’s security advisory policy.
Browser language detection that works with full page caching — plus a language switcher choice that sticks.
The problem
Drupal core cannot combine three things every small multilingual site wants: detecting the language of anonymous visitors from the browser's Accept-Language header, letting a manual language-switcher choice overrule that detection, and keeping the Internal Page Cache enabled.
This is the subject of core issue #2430335 — Browser language detection is not cache aware, open since 2015. The Internal Page Cache keys pages by URL only, so a page cached in one language would be served to visitors preferring another. Core "solves" this by disabling the page cache entirely whenever browser detection runs (page_cache_kill_switch) — every anonymous page view hits PHP. And even then, a manual switcher choice does not persist: on the next visit the browser language wins again.
Typical symptoms without this module: either wrong-language pages served from cache (with certain negotiation configurations), or no page caching at all, or a front page that is hard-wired to the default language because an empty path prefix wins the negotiation before browser detection is ever consulted.
Features
What this module does
- Language-aware page cache. Replaces the Internal Page Cache middleware with a subclass that keeps one cache entry per URL per language. A cached page can never be served in the wrong language.
- Cache-safe browser detection. Replaces the core Browser negotiation plugin with a subclass that does not trigger the page-cache kill switch — safe because of (1). Full page caching stays enabled for all anonymous visitors.
- Front page fix. With path-prefix negotiation and an empty prefix for the default language, core hard-wires the bare front page
/to the default language. This module lets browser detection decide there. - Persistent manual choice. Provides a Preference cookie negotiation method plus ~1 kB of dependency-free JavaScript: clicking a language-switcher link stores the choice in a cookie (1 year), which from then on overrules the browser language.
- Canonical URLs. When negotiation on
/resolves a language with a non-empty prefix, the visitor is 302-redirected to that language's front page (e.g./de), so the address bar and all generated links stay canonical.
No core patch, no forked modules. Uninstalling restores exact core behavior.
How it stays correct
The cache middleware resolves the cache-key language by walking the same enabled negotiation methods in the same configured order as the language negotiator itself — preference cookie, Browser (UserAgent::getBestMatchingLangcode() with the site's configured mappings), and Selected language (honoring a non-default selection) — skipping only methods that cannot apply to page-cacheable requests (URL: already in the cache key via the request URI; User/Session/Account administration pages: session-bound, and sessions bypass the page cache). Cache key and rendered language therefore cannot diverge, regardless of how the methods are ordered. Cookie and header values are validated against the configured language list on both the negotiation and the caching side — invalid input always degrades to "no choice made", never to a poisoned cache, and the number of cache partitions is bounded by the number of configured languages.
One constraint follows from caching per langcode: anonymous responses must not vary on anything finer than the resolved langcode (e.g. en-US vs en-GB regional content) — stock Drupal masked such variance only by disabling the page cache entirely.
Requirements
- Drupal 10.3+ or Drupal 11
- Core Language and Internal Page Cache modules
- Two or more configured languages
- URL negotiation using path prefixes (an empty prefix for the default language is fine — that case is the module's specialty)
Supported scenarios
The module was built for one concrete setup (two languages, empty prefix for the default language, core switcher semantics), but its correctness does not depend on those specifics. What is and is not covered:
Works
- Any number of languages (two or more). All matching iterates the configured language list; nothing is hard-coded to two.
- Default language with an empty path prefix (e.g.
/= English,/de= German): the module's specialty. The default language keeps/as its canonical front page; other languages get redirected to their prefix. - All languages with explicit prefixes (e.g.
/en,/de): also supported — the front-page fix degrades to a no-op (core never matched/in that case anyway) and/then 302-redirects every visitor to their negotiated language's front page. - Additionally enabling the core Account administration pages, User or Session methods: safe in any position. These methods only ever apply to visitors with a session, and sessions bypass the Internal Page Cache entirely — they cannot interact with the cache partitioning.
- Browser enabled without the Preference cookie method (or vice versa): correct, with gracefully reduced features (no persistent manual choice / no first-visit detection, respectively). The cache middleware only models methods that are actually enabled.
- Authenticated users: unaffected in every respect (they never touch the Internal Page Cache).
- Any reverse proxy in front (HAProxy, nginx, ...) as long as it forwards
Accept-LanguageandCookieunmodified and does not run its own full-page cache (see below).
Not supported / out of scope
- Domain-based URL negotiation (one domain per language). Harmless but pointless: the URL method then decides every request including
/, so cookie and browser methods never run; the front-page redirect is deliberately inactive (guarded to path-prefix mode). Domain sites do not have the #2430335 problem in the first place. - Additional negotiation methods that vary for session-less anonymous visitors — e.g. contrib IP/country-based negotiation. The cache middleware does not model them, so cache key and rendered language could diverge. Such methods require extending
LanguageAwarePageCache::getRequestLangcode()accordingly (a well-commented single method) — or must not be combined with this module. - Customized Content language detection that uses Browser or the Preference cookie differently from Interface text. Keep "Customize Content language detection" unchecked (content follows interface), or customize it only with methods that don't vary for anonymous visitors.
- External full-page caches (Varnish, CDN page caching, a proxy-level cache such as HAProxy's): outside this module's control. They must either honor
Vary: Accept-Languageand vary on theblcf_langcookie, or bypass caching for HTML. (The module makes such aVaryheader truthful; core alone does not.)
Robust by construction
- The cache middleware mirrors the actual configured negotiation method order (from
language.types), including a Selected language set to a non-default language. Reordering the core methods can therefore degrade the user experience (e.g. Browser above the cookie means manual choices stop sticking) but can never desynchronize the cache key from the rendered language. The documented order below is the UX-correct one.
Installation and configuration
- Install the module as usual.
- Go to Configuration → Regional and language → Languages → Detection and selection and configure the Interface text methods in this order:
- URL — enabled
- Preference cookie — enabled (above Browser, so manual choices win)
- Browser — enabled
- Selected language — enabled (fallback)
Account administration pages, User and Session can stay disabled (optional — see Supported scenarios).
- Leave "Customize Content language detection" unchecked, so content follows the interface language.
- Rebuild caches (
drush cr).
Why this order: URL asserts prefixed pages (/de/...), the cookie carries the manual choice, the browser decides for first-time visitors, Selected language is the final fallback. A different order cannot corrupt the cache (see Supported scenarios), but this one gives the intended UX.
Post-Installation
Verifying
Run twice each (no cookies): a request with Accept-Language: de against the front page must answer 302 to /de; with Accept-Language: en (default language English, empty prefix) it must answer 200 with Content-Language: en and, on the second run, X-Drupal-Cache: HIT. Adding Cookie: blcf_lang=de to an English-browser request must behave like the German one — the manual choice wins.
Additional Requirements
Language switcher compatibility
The preference cookie is written client-side on switcher clicks. Supported out of the box: the core Language switcher block (via hreflang / language-link), the Advanced language selector module (language derived from the link's URL prefix), and any switcher whose links carry hreflang, the language-link class, or sit inside a wrapper whose class contains language-switcher.
Developers: adapting the script to any other switcher is a one-line change in js/preference-cookie.js, documented step by step in the README's "Adapting to another language switcher" section — including the two signals the script uses (hreflang attribute, URL prefix matched against drupalSettings.browserLanguageCacheFix.prefixes) and the single contract a custom implementation must fulfill (write a valid langcode into the blcf_lang cookie).
Recommended modules/libraries
Used and tested with Advanced Language Selector module.
Similar projects
- Core patches (issue #2430335, MRs !6512 / !8878): unmerged after ten years; require patching core on every update.
- Homepage Redirection by Language: covers the front-page redirect and cookie, but requires uninstalling the Internal Page Cache. This module keeps it on.
- Language Cookie: cookie negotiation, but has known conflicts with the Internal Page Cache for anonymous users.
- Reverse-proxy tricks (
Vary: Accept-Languageat HAProxy/Varnish/CDN): do not help, because the Internal Page Cache ignoresVaryentirely. (With this module installed, such aVaryheader becomes truthful and useful for downstream caches.)
Supporting this Module
If you want to help and improve Drupal in general: write BETTER documentation of your modules! BETTER means address more target groups - not just developers!
Limitations
- Visitors who make a manual choice and also browse URL-prefixed pages of another language will see the URL win — by design: an explicit URL beats a stored preference.
- The module overrides three core classes (page cache middleware, Browser and URL negotiation plugins) via supported extension points. These APIs have been stable since Drupal 8, but re-testing after core minor updates is recommended (the four-request curl check above takes a minute).
- For scenario boundaries (domain negotiation, contrib negotiation methods, external caches), see Supported scenarios above.
Credits
Developed to resolve a real-world language-inversion problem on a bilingual (EN/DE) Drupal 11 site behind a HAProxy reverse proxy, after tracing the behavior to core issue #2430335. Developed and checked for security flaws to the best of my knowlede and with the help of fable 5.
Project information
Minimally maintained
Maintainers monitor issues, but fast responses are not guaranteed.Maintenance fixes only
Considered feature-complete by its maintainers.- Project categories: Multilingual
- Ecosystem: Language Selection Page, Language Switcher, Advanced Language Selector
- Created by maxilein on , updated
This project is not covered by the security advisory policy.
Use at your own risk! It may have publicly disclosed vulnerabilities.
