The Card was about selling more courses:
As an Instructor, I want my site available in multiple languages (EN and pt-BR), so that I have a better chance of selling courses to international students.
Refinement immediately called it epic-sized — Studio, Student learning surfaces, transactional emails, marketing pages, all in scope for “multi-language.” /pbl-split carved out the first, load-bearing bite: the locale infrastructure itself, proven end-to-end on the public pages a not-yet-registered visitor actually sees — home, catalog, course detail, checkout. Everything else (Student surfaces, Studio, emails) waits behind gettext’s own fallback, which is what makes slicing it this way safe in the first place: an untranslated screen never breaks, it just shows English.
One plug, four levels, in order
Every request resolves to exactly one of two Locales — en or pt — and it always resolves the same way, whether the visitor is anonymous, a returning Student, or mid-checkout:
flowchart TD
R[Request] --> LI{"Logged in?"}
LI -->|yes| PL["Profile.preferred_language"]
LI -->|no| CK{"locale cookie set?"}
CK -->|yes| CKV["cookie value"]
CK -->|no| AL{"Accept-Language matches en/pt?"}
AL -->|yes| ALV["negotiated locale"]
AL -->|no| DEF["en (default)"]
style PL fill:#F2E3C2,stroke:#B5912A
style CKV fill:#F2E3C2,stroke:#B5912A
style ALV fill:#F2E3C2,stroke:#B5912A
style DEF fill:#f6f5f2,stroke:#bbb,stroke-dasharray:4 3
That precedence lives in exactly one place — ForgiaWeb.Plugs.Locale — and nowhere else reads a cookie or an Accept-Language header:
defp resolve(conn) do
user_locale(conn) || cookie_locale(conn) || accept_language_locale(conn) || Locale.default()
end
That single-plug rule isn’t tidiness for its own sake — it’s the spine of ADR 0018, the decision to resolve language by cookie + stored preference rather than URL-prefixed routes (/pt-br/catalog). Routes are the stronger play for SEO and per-language campaigns, but go-to-market is still unsettled, and the expensive work — wrapping every string, authoring the pt-BR translation — is identical either way. So the ADR calls the current shape “routes-ready” and holds it to three constraints: resolution stays in one plug, the switcher stays one isolated component, and every public URL goes through a path helper. Whichever way the business decides to acquire international students, the translation work already done doesn’t get thrown away.
The switcher is one component, twice
The header carries a small EN / PT pill — mm-prefixed, per this codebase’s standing rule about not colliding with daisyUI’s bare class names. It posts to a single route:
def language_switcher(assigns) do
~H"""
<form method="post" action={~p"/locale"} class="mm-lang-switcher" ...>
<button :for={locale <- Forgia.Locale.supported()} type="submit" name="locale" value={locale}
class={["mm-lang-option", locale == @current_locale && "active"]}>
{String.upcase(locale)}
</button>
</form>
"""
end
The /profile page renders the exact same component, not a lookalike — so “change it in the header” and “change it in Settings” are the same write, never two settings that can drift apart.

An anonymous click writes a locale cookie. A logged-in click does something stronger: it persists to Profile.preferred_language, so the choice survives a login from a different browser entirely — not just this session.

The invariant that had to give
Here’s where the “so that” — selling to international students — ran into a Profile that predates this feature. Preferred Language, per ADR 0008, belongs on Profile, the aggregate that already holds a Student’s personal data separately from their auth account. But Profile has always been created lazily — on Invitation-claim, or the first time someone saves /profile — and profiles.name has been NOT NULL since the very first migration.
The acceptance criteria wanted the language seeded from Accept-Language at account confirmation, before a self-registered, magic-link Student has ever opened /profile, possibly ever. Which means: no name yet, but a Profile row needs to exist to hold the locale. Those two facts can’t both be true under the old constraint.
So ADR 0019 drops the NOT NULL, and a Profile is now created for every account at confirmation — self-registered or invited alike — seeded with a language and, for now, nothing else:
def language_changeset(profile, attrs) do
profile
|> cast(attrs, [:user_id, :preferred_language])
|> validate_required([:user_id, :preferred_language])
|> validate_inclusion(:preferred_language, Forgia.Locale.supported())
|> unique_constraint(:user_id)
end
The changeset never touches name, on purpose — it can’t, since seeding a Student’s language must never clobber a name an Invitation already claimed for them. The two paths that write a Profile are ordered so that can’t happen: claim_invitations/1 runs before the locale seed in login_user_by_magic_link/2, every time.
The consequence worth writing down for whoever reads this next: “Profile exists” no longer means “this person has been personalized.” A name-less Profile — locale set, nothing else — is now a normal, common state, not an edge case. Any code that used get_profile/1 == nil as a stand-in for “hasn’t touched their profile yet” is checking the wrong thing now; it has to look at name directly. nav_profile/1 already did — display-name fallback was written to handle a blank name regardless of whether the row existed — so nothing broke. But it’s the kind of thing that’s obvious in the diff and invisible a month later, which is exactly what the ADR is for.
What a visitor actually sees
Land on the site with a Brazilian browser and no account yet, and the negotiation just works — no cookie, no login, just Accept-Language doing its job:

Flip the switcher on the catalog and the whole page repaints — including the plural, which is where ngettext earns its keep. Portuguese pluralizes differently than English (no “1 cursos”), so every counted string — courses, chapters, lessons, reviews — goes through ngettext, never a bare if count == 1:
{gettext("Catalog")} · {ngettext("%{count} course", "%{count} courses", length(@courses))}

That last detail in the caption is a boundary this item was careful never to cross: Course content is not UI chrome. Course.language — the field that says a course was authored in English or Portuguese — is untouched by any of this. A pt-BR visitor gets a pt-BR interface around an English-authored course, not a machine-translated course. The distinction is explicit in the glossary now: Locale is “the resolved active language for a request… distinct from Course.language, which is the authored language of a Course’s content and is never translated.”
The purchase panel — checkout, in the loosest sense the Card meant it — gets the same treatment, chrome translated, nothing about the Course itself touched:

What shipped, and what’s next
Three new domain terms landed in CONTEXT.md alongside the two ADRs: Locale, Preferred Language, Language Switcher — vocabulary the rest of the epic will keep reusing without redefining. The deferred half of the original Card — Student learning surfaces, Studio, and transactional emails — is its own item now, Localized App Interior & Emails, sitting safely behind the same gettext fallback until it’s next.
The infrastructure this item built doesn’t get rebuilt for that next slice. It gets reused.