Skip to content

Give the app a language: store the user's locale and translate every email - #341

Merged
paulocastellano merged 25 commits into
mainfrom
feature/user-locale-in-database
Sep 8, 2026
Merged

paulocastellano merged 25 commits into
mainfrom
feature/user-locale-in-database

Conversation

@paulocastellano

@paulocastellano paulocastellano commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

The app never knew what language a user reads, and every email was hardcoded English. The first half is what makes the second possible.

Part 1 — the locale moves to the database

The UI locale lived in a forever cookie since March 2026, so it belonged to a browser rather than to a person: a new device, a cleared cookie or a second browser all dropped the user back to English.

The enum is the only list of locales. App\Enums\User\Locale is new and is now the single source of truth — the column cast, the switcher options, request validation and the <html dir> attribute all come from it. config/languages.php duplicated the same 16 entries and is deleted. LocalizationParityTest guards both directions: every case must ship a lang/<value> directory, and the list must stay in step with ContentLanguage.

SetLocale has one rule. An authenticated request renders in Auth::user()->locale; everything else in Locale::DEFAULT. It does not read the request body, old input or Accept-Language. A logged-out visitor always gets English from the server, validation messages included.

Capture happens on the two screens with an account attached. Register submits locale as a required hidden field and creates the user with it. Login submits it only once the visitor picks a language — otherwise the field goes out empty and the stored locale is left alone. That asymmetry is load-bearing: the login screen always renders in the default, so an always-sent field would reset every non-English user to English on each sign-in. Forgot and reset password do not send it at all.

The switcher is client-side. It sits in the auth layout, so all four logged-out screens show it, and it renders only for a visitor — VerifyEmail and the workspace screens share that layout while authenticated, where the language comes from the account. Picking a language calls loadLanguageAsync, so it costs no round trip; useGuestLocale holds the choice at module scope so it survives Inertia navigation between those screens, and clears it on authentication. Flags are the 16 country SVGs in public/images/flags, mapped from the enum.

Migration: users.locale, string(10), default en, no backfill.

PostHog: locale syncs as a person property at the top level rather than in $set_once — it is current state, not a first-touch fact like signed_up_at. The email automations read it to pick a translation. The project's schema had no language-related person property, so nothing is overwritten; $browser_language was not an option, since it exists only as an event property on $pageview and cannot segment a person.

Part 2 — every email speaks the user's language

Three things the survey turned up:

  • Nothing read the recipient's locale. Not one ->locale() call or HasLocalePreference implementation existed. Even the single already-translated email resolved against config('app.locale'), so it always went out in English.
  • That email used a pattern that does not scale — it resolved eight strings in PHP and injected each as a view variable, so every new sentence had to be threaded through the Mailable while the template gave no hint it was translatable.
  • Two emails were not even Mailables — verification and password reset were closures in AppServiceProvider with literal subjects.

User implements HasLocalePreference. This is what makes the rest work: Mail::to($user) and $user->notify(...) localize on their own, and no send site passes a locale. The two places passing a bare email string had to start passing the model. The invite is the deliberate exception — the recipient has no account yet, so it sends in the inviter's locale.

Copy lives in the template. Each of the ten templates uses __('mail.<slug>.<key>'), one lang/*/mail.php block per template keyed by the slug, shared chrome under layout. Mailables keep only subject, title and preview, and otherwise pass data, never sentences. webhooks.mail.* moved to mail.webhook_paused.

All 16 locales, all ten emails. The translation pass changed no layout — those diffs only replace hardcoded text with __(), and every existing content assertion still passes. Three design changes ride along, each requested separately: buttons lost their trailing arrow, moved to the product's primary purple (#7c3aed), and the footer points at the new Instagram handle.

One subtle fix: the last plural segment is [0,*] rather than [2,*], because PostAtRisk can report a count of zero and an unmatched count renders a stray leading space.

Part 3 — making the switch actually visible

Removing the cookie exposed a defect that had always been there: app.ts read the locale once at boot and never again. That was invisible while the cookie made /login already render in the returning user's language, but a guest now always boots in English — so signing in as a Japanese user left the whole SPA in English until a hard reload.

  • The locale is re-applied on Inertia success (not navigate: updateLanguage answers with back(), same URL, so Inertia updates props without reporting a navigation).
  • Text direction is applied on every switch, resolved from the enum.
  • Date formatting reads the locale off a ref. dayjs.locale() is global and not reactive, so a computed formatting a date had nothing to invalidate it and kept serving the previous language — the interface strings changed while every date stayed one switch behind.
  • All of it moved out of app.ts into resources/js/language.ts.
  • The in-app switcher no longer forces window.location.reload().
  • The sidebar used physical left-0/right-0, which does not follow an RTL document; it now uses logical properties. The rest of the app has never been walked in RTL — RTL: sweep physical direction classes across the app #342 tracks that.

Tests

  • tests/Unit/Enums/User/LocaleTest.php — every case ships a translation directory and a flag file that exists.
  • tests/Unit/DayjsLocaleParityTest.php — every case has its dayjs locale imported. A missing import is ignored silently by dayjs, so it would surface as dates in the wrong language rather than an error.
  • tests/Feature/Middleware/SetLocaleTest.php — the two rules, and that a guest cannot change the rendered locale by submitting one.
  • tests/Feature/RegistrationLocaleTest.php / Auth/LoginLocaleTest.php — the picked locale is stored on signup and on login, an untouched picker leaves the stored value alone, unsupported values are rejected without touching what was saved, and SyncUser fires.
  • tests/Feature/Mail/MailLocalizationTest.php / MailRenderingTest.php — each recipient gets their own locale, and a rendered email contains the translated copy and not the English. Verification and password reset go through the real notification sender, since calling toMail() directly would pass with no localization at all.
  • tests/Browser/AuthLanguageSwitcherTest.php / SidebarLanguageSwitchTest.php — the switcher on all four logged-out screens, hidden once authenticated, the choice surviving navigation, switching in place without a reload, the week and month headers following the language, and Arabic flipping the document to RTL.

Full suite green on PostgreSQL (4495 passed), 56 browser tests, affected areas green on MySQL.

Paulo Castellano added 3 commits September 7, 2026 18:31
The locale lived in a forever cookie since March 2026, so it was tied to a
browser rather than to the person: a new device, a cleared cookie or a second
browser all dropped the user back to English, and the register page had no way
to know what language the visitor actually reads.

Move it back onto `users.locale`, cast to a new `App\Enums\User\Locale` that is
now the only list of supported locales — `config/languages.php` duplicated it and
is gone. `App\Support\LocaleResolver` is the single place that decides the active
locale: the stored value for an authenticated user, and for a guest the submitted
`locale`, the flashed old input, a negotiated `Accept-Language`, then the default.
The first two guest steps are what keep the backend validation messages, and the
re-rendered form after a failed submit, in the language the visitor picked.

The register page gains a language picker seeded from `Accept-Language`, which
translates the page client-side so choosing a language costs no round trip. The
in-app switcher now writes to the database instead of the cookie, and its inline
validation moves into a dedicated `UpdateLanguageRequest`.

Google and GitHub signups always store English: they have no picker, and the
OAuth callback's `Accept-Language` describes the provider's redirect rather than
the person. Existing users keep the column default for the same reason.
The register form always submits the field — the hidden input falls back to the
`locale` page prop, which the controller always fills — so a missing value means
a broken client rather than a visitor without a preference. Existing signup tests
submit it explicitly for the same reason.
All ten Maizzle templates carried English copy in the HTML, and nothing read the
recipient's language: there was no `HasLocalePreference` implementation and no
`->locale()` call anywhere, so even the one email that was already translated
resolved its strings against `config('app.locale')` and always went out in
English.

`User` now implements `HasLocalePreference`, which is what makes the rest work —
`Mail::to($user)` and `$user->notify(...)` localize on their own, so no send site
needs a locale argument. The two sites that passed a bare email string had to
start passing the model to get it. The invite is the deliberate exception: the
recipient has no account yet, so it inherits the inviter's locale.

Copy moves into the templates as `__('mail.<slug>.<key>')`, one `lang/*/mail.php`
block per template. Mailables keep only the envelope metadata the layout needs
and otherwise pass data, never sentences — the previous approach resolved eight
strings in PHP and injected them as view variables, which meant every new
sentence had to be threaded through the Mailable while the template gave no hint
it was translatable. Verification and password reset were not even Mailables;
their subjects were literals inside AppServiceProvider closures.

`webhooks.mail.*` moves to `mail.webhook_paused` so email copy lives in one file
rather than in whichever feature happens to send the mail.

The last plural segment is `[0,*]` rather than `[2,*]` throughout: PostAtRisk can
report a count of zero when rows disappear between dispatch and send, and an
unmatched count renders a stray leading space in the subject.
@paulocastellano paulocastellano changed the title Store the user's UI locale in the database Store the user's UI locale in the database and translate every email Sep 7, 2026
Paulo Castellano added 21 commits September 7, 2026 19:10
States what is now true — no English-only email is left — and that both halves
are mandatory for anything new: a hand-written Blade view under
resources/views/mail loses the shared layout and drops out of the translation
workflow, and a literal string is invisible to LocalizationParityTest, so it
ships and stays broken.
The trailing "→" was decoration on every call to action, and the buttons were
still on Maizzle's default near-black. They now use the app's primary token,
#7c3aed, so an email button looks like a button in the product.

Also adds `mail:preview {email} --locale=`, which sends one of every email to a
single address so the rendered result can be checked in a local mail catcher.
The sample records it needs — three Mailables query their relations while
rendering — are created in a transaction that is always rolled back.
The email automations run from PostHog, so they need to know which language to
send in — and PostHog had nothing to tell them. Checking the project's schema
first: no person property relates to language at all, so `locale` collides with
nothing and overwrites nothing.

`$browser_language` and `$browser_language_prefix` are not an alternative. They
exist, but only as event properties on `$pageview`, so they can neither segment a
person nor feed an automation, and they report the browser's language at that
pageview rather than the language the user chose.

`locale` goes at the top level rather than in `$set_once`: it is current state,
not a first-touch fact like `signed_up_at` or the attribution keys, and a
cadence must follow the user when they switch languages. Registration already
syncs via CreateUser; the language switcher now dispatches SyncUser too.
The switcher lives in the auth layout rather than on the register page, so
login, register, forgot password and reset password all offer it. It renders
only for a logged-out visitor: VerifyEmail and the workspace screens share this
layout while authenticated, and there the language already comes from the
account.

`useGuestLocale` holds the choice at module scope so it survives Inertia
navigation between those screens, and all four forms submit it as a hidden
`locale` field. Only register persists it — for the other three it exists so
LocaleResolver renders the backend validation messages in the language on
screen rather than in English.

Flags come from the 16 country SVGs copied into public/images/flags, mapped
from the enum. A flag is a country rather than a language, so `flag()` picks the
most recognisable stand-in rather than making a claim about where a language is
spoken. The in-app sidebar switcher shows them too.
The account moved to instagram.com/trypost.en.
The verification and password reset previews arrived with a translated subject
and an English body. `toMail()` resolves the subject, but the view is rendered
later by `render()`, which was called after the locale scope had already been
restored — so only the half built inside the scope came out translated.

This was a bug in the preview command alone. The real sends go through the
notification sender, which wraps the whole render, and MailRenderingTest has
been asserting that end to end since it was written.

MailPreviewTest covers the command itself now: it fails when the render moves
back outside the scope.
`--only=password-reset --only=email-verification` sends just those two, so
iterating on one email does not bury a mail catcher in nine others. An unknown
slug sends nothing rather than falling back to everything.
Removes 64 dead translation lines: the `auth.register.language*` keys were
written for the in-form picker, which the layout dropdown replaced without ever
using them. Also merges two separate `vue` imports in useGuestLocale.

Comments now carry only what the code cannot: why a flag is a country rather
than a language, why the resolver checks input and old input before the header,
why the mail render test goes through the notification sender instead of
calling toMail. Everything that restated a method name is gone.
The switcher always has a language selected, so negotiating a locale out of the
request header bought nothing: fromTag, fromAcceptLanguage and primarySubtag are
gone, and a guest now resolves from the submitted locale, the flashed old input,
then the default.

`users.locale` is NOT NULL with an `en` default, so `?? Locale::DEFAULT` in
preferredLocale, LocaleResolver and SyncUser was guarding a state the schema
does not allow — it only hid a real null behind a plausible value. The two tests
that existed to exercise that impossible null are gone with it. The remaining
defaults are ones that can actually fire: a guest with nothing submitted, a
social signup that carries no locale, and an invite whose sender was deleted.

Comments are down to @PARAM and @return.
Updated the CreateInvite action to use the inviter's preferred locale instead of the default. In CreateUser, adjusted locale assignment to ensure it correctly defaults to the Locale enum. Improved language handling in useGuestLocale to validate language codes before setting them. Enhanced AuthLanguageSwitcher to conditionally render language flags and names. These changes streamline locale management across user invitations and registrations.
The page loads in the system default from the backend prop and the dropdown
switches from there. Setting `lang` and `dir` on the document went with it, so
the `dir` the enum was shipping in its options is gone too — the middleware
still renders it into the Blade root for the request itself.
The middleware is down to one rule: an authenticated request renders in the
user's locale, everything else in the default. `LocaleResolver` existed only to
feed it, so it is gone — the guest steps that read submitted and old input went
with it, and with them the translated backend validation messages.

Login now carries the same required `locale` field as register and stores it, so
picking a language on the login screen follows the user into the app. Forgot and
reset password no longer send the field: nothing reads it there.
CLAUDE.md and AGENTS.md still described LocaleResolver, Accept-Language
negotiation and the guest input steps — none of which exist. They now describe
the one rule the middleware has, that the auth switcher is client-side only, and
that login writes the locale as well as register.

LoginRequest also used `new Enum(...)` where the other thirteen requests use
`Rule::enum(...)`; it was working around the contract already imported as `Rule`,
which an alias handles.
The login screen always renders in the default, so an always-sent hidden field
meant every login wrote 'en' — a user who chose Japanese in the app was reset to
English the next time they signed in, and their next email arrived in English.
That defeats the point of storing the locale at all.

The field now goes out empty until someone opens the dropdown, and an empty
value leaves the stored locale alone. Register keeps its required field: there
the value is the account being created, not an override of something existing.

LoginLocaleTest missed this because every case submitted a locale that differed
from the stored one. It now covers the untouched picker, and that the locale the
login screen renders in never overwrites what is saved.
`SetLocale` renders `htmlDir` from the authenticated user, so a logged-out
visitor always gets `ltr` — picking العربية translated the auth screen but left
it laid out left to right, with no later request to correct it.

The switcher now sets `document.documentElement.dir` from the picked language.
The value still comes from the enum (`direction()`, back in `options()`), so
the rule that only Arabic is RTL stays in one place.

Not covered by a browser test: `assertAttribute` cannot reach `<html>`, which
Playwright does not treat as visible.
The browser testing rules require data-testid selectors; this one clicked a
translated string, so it would have broken on any copy change to the login
screen — including a change to the Portuguese translation it depends on.
The last inline `$request->validate()` in ProfileController, left over from
before the convention. Also covers the size half of the rule, which had no test
— only the non-image case did, so `max:2048` could have been dropped silently.
app.ts read the locale once at boot and never again, which was fine while the
cookie made /login already render in the returning user's language. Now a guest
always boots in English, so signing in as a Japanese user left the whole SPA —
strings, dayjs and <html lang> — in English until a hard reload.

It now re-applies on every Inertia navigation, guarded by getActiveLanguage()
so an ordinary visit costs nothing. That also removes the reason the in-app
switcher forced `window.location.reload()`: changing language is a normal
Inertia visit now.

A guest's own pick wins over the shared prop, which is always the default while
logged out — without that, navigating from login to register reverted it. The
pick is cleared on authentication so it cannot replay into the next session.
app.ts had accumulated the boot read, the dayjs alignment, the i18n plugin
options and the navigation hook, and the visitor's pick lived in a Vue
composable even though it is plain module state. All of it is one concern, so
it now lives in one file.

Splitting it also fixed an ordering smell: boot only aligns dayjs and
`<html lang>`, since the i18n plugin loads the strings itself from the locale it
is given — the previous version called loadLanguageAsync before the plugin
existed. useGuestLocale is now a thin Vue wrapper over the module.
Switching language translated the interface strings but left every date one
language behind: a Portuguese page showed "7–13 September", an English one
"7–13 Setembro". The i18n plugin is reactive, so `$t` re-rendered, but
`dayjs.locale()` is global and not reactive — a computed formatting a date had
no dependency to invalidate it, so it kept serving the previous language.

Formatting now reads the locale off a ref, in date.ts (the project's single
formatting entry point, so every caller is covered) and in Calendar.vue, which
uses dayjs directly.

The sidebar switch is also hooked to `success` rather than `navigate`:
updateLanguage answers with `back()`, which keeps the same URL, so Inertia
updates the props without reporting a navigation and nothing re-applied the
locale. Direction is applied on those switches too, resolved from the shared
languages prop.

Not a bug, though it looks like one next to this: the week runs 7–13 in English
and 6–12 in Portuguese because Carbon starts the week on the locale's own first
day.
The sidebar pinned itself with physical `left-0` / `right-0` picked from the
`side` prop. In Arabic the document flow inverts but a `fixed left-0` does not
follow it, so the sidebar overlapped the content and left a dead strip on the
other edge. Logical properties (`start-0`, `end-0`, `border-e`, `border-s`) let
the browser resolve it from `dir`, with no conditional and no change to LTR.
The rest of the app has never been walked in RTL — issue #342 tracks that.

The calendar fix was also only half done: the week header went through the
reactive locale but the day and month views still used bare `dayjs()`, so those
kept the previous language's names. Every date on the screen now goes through
one `localized()` helper.

DayjsLocaleParityTest guards the enum against dayjs: a locale that was never
imported is ignored silently and the previous one stays active, so a missing
import would show up as dates in the wrong language rather than as an error.
@paulocastellano paulocastellano changed the title Store the user's UI locale in the database and translate every email Give the app a language: store the user's locale and translate every email Sep 8, 2026
@paulocastellano
paulocastellano merged commit 05072c4 into main Sep 8, 2026
5 checks passed
@paulocastellano
paulocastellano deleted the feature/user-locale-in-database branch September 8, 2026 02:03
paulocastellano pushed a commit that referenced this pull request Sep 8, 2026
The sweep removed 108 files under resources/js/components/ui because nothing
imported them. That reasoning does not hold there: those are the shadcn-vue
primitives, kept as a library to build from rather than as application code, so
being unimported is their normal state, not evidence they are dead. The
directory is now byte-identical to main again.

Also merges main, which brought the locale work in (#341). The two test files
both branches touched — WebhookPausedMailTest and WebhookTranslationsTest —
combined without conflict.
paulocastellano pushed a commit that referenced this pull request Sep 8, 2026
* Remove dead code surfaced by the automations removal sweep

Closes #334.

Everything here had zero importers on main, verified by grepping for the
import path or class name across resources/js, app, config, routes, tests
and resources/views.

Frontend: the leftovers of the Jan 2026 starter kit (AlertError, AppContent,
AppShell, Breadcrumbs, Heading, Icon, NavFooter, PlaceholderPattern,
UserInfo, WorkspaceSwitcher, GuestLayout, AuthCardLayout, AuthSimpleLayout,
useDateMaska) plus 18 shadcn ui/ directories nothing ever imported.

ui/sheet and ui/range-calendar stay, against what #334 listed: sheet backs
the mobile drawer in ui/sidebar/Sidebar.vue, which AppSidebar renders, and
range-calendar backs ui/date-range-picker, used by the analytics page. The
"no importer outside ui/" criterion missed those transitive edges.

posts/previews/LinkCard.vue also stays; it is live in the X, Threads and
Bluesky previews. The sweep's grep only covered single-quoted imports and
that one is double-quoted.

ui/input/InputMask.vue was not on the list but is an orphan too, and it was
the last thing holding maska.

PHP: PasswordValidationRules, the Ai\Orientation enum (the image pipeline
passes raw 'portrait'/'landscape' strings instead) and QuotaExhaustedException.

npm: axios, embla-carousel-vue, vue-input-otp, vaul-vue and maska drop out of
package.json. axios stays in the tree as a transitive dependency of
@inertiajs/vue3, it just is not ours to declare any more.

* Make browser tests read the built assets even with the dev server running

tests/BrowserTestCase already says these tests load the built Vite assets,
but it only turned off the manifest fake. Laravel still prefers public/hot
when it exists, so on any machine with `npm run dev` running the browser
tests silently loaded the app from the Vite dev server instead of the build
that `npm run build` had just produced.

That is enough on its own to make three tests fail locally while CI, which
has no hot file, stays green. The dev server resolves
`import.meta.glob('../../lang/*.json')` lazily at runtime, and the
laravel-vue-i18n Vite plugin deletes lang/php_*.json when a build finishes,
so the page rendered raw translation keys -- "auth.legal" instead of the
sentence with the Terms of Service and Privacy Policy links, and the raw
repurposes.health.source_missing key instead of the banner.

Pointing the hot file at a path that can never exist makes the browser tests
use the manifest unconditionally, which is what they claim to do and what CI
has been doing all along.

    AuthLegalLinksTest        the login screen shows the legal sentence
    AuthLegalLinksTest        the register screen shows the legal sentence
    RepurposeAccountHealthTest  a repurpose whose source was deleted ...

The HTTP server the browser plugin runs lives in the test process
(Pest\Browser\Drivers\LaravelHttpServer resolves the kernel out of the same
container), so a setUp() override reaches the rendered page.

* Keep every UI primitive, and merge main

The sweep removed 108 files under resources/js/components/ui because nothing
imported them. That reasoning does not hold there: those are the shadcn-vue
primitives, kept as a library to build from rather than as application code, so
being unimported is their normal state, not evidence they are dead. The
directory is now byte-identical to main again.

Also merges main, which brought the locale work in (#341). The two test files
both branches touched — WebhookPausedMailTest and WebhookTranslationsTest —
combined without conflict.

* Refactor BrowserTestCase by removing unused Vite setup

This commit cleans up the BrowserTestCase by eliminating the unused Vite facade and the associated setup method, which was previously intended to configure a hot file that never existed. The class now focuses solely on its core functionality without unnecessary dependencies.

* Keep the dependencies the UI primitives import

The sweep dropped five packages as unused, but four of them are imported by the
primitives under resources/js/components/ui — restoring those files without the
packages broke the build, which is what the e2e job hit: "Rolldown failed to
resolve import maska/vue".

vaul-vue (drawer), vue-input-otp and embla-carousel-vue (carousel) come back:
if the primitives stay as a library to build from, so do the packages they
depend on. axios and maska stay out — nothing imports either, maska only
mattered for InputMask.vue, which is gone.

Also drops the Vite hot-file override from BrowserTestCase. It forced the tests
onto public/build even with the dev server running, so locally they read
whatever was last built rather than the code under test.
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.

1 participant