Give the app a language: store the user's locale and translate every email - #341
Merged
Merged
Conversation
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.
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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\Localeis 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.phpduplicated the same 16 entries and is deleted.LocalizationParityTestguards both directions: every case must ship alang/<value>directory, and the list must stay in step withContentLanguage.SetLocalehas one rule. An authenticated request renders inAuth::user()->locale; everything else inLocale::DEFAULT. It does not read the request body, old input orAccept-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
localeas 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 —
VerifyEmailand the workspace screens share that layout while authenticated, where the language comes from the account. Picking a language callsloadLanguageAsync, so it costs no round trip;useGuestLocaleholds the choice at module scope so it survives Inertia navigation between those screens, and clears it on authentication. Flags are the 16 country SVGs inpublic/images/flags, mapped from the enum.Migration:
users.locale,string(10), defaulten, no backfill.PostHog:
localesyncs as a person property at the top level rather than in$set_once— it is current state, not a first-touch fact likesigned_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_languagewas not an option, since it exists only as an event property on$pageviewand cannot segment a person.Part 2 — every email speaks the user's language
Three things the survey turned up:
->locale()call orHasLocalePreferenceimplementation existed. Even the single already-translated email resolved againstconfig('app.locale'), so it always went out in English.AppServiceProviderwith literal subjects.UserimplementsHasLocalePreference. 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>'), onelang/*/mail.phpblock per template keyed by the slug, shared chrome underlayout. Mailables keep only subject, title and preview, and otherwise pass data, never sentences.webhooks.mail.*moved tomail.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,*], becausePostAtRiskcan 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.tsread the locale once at boot and never again. That was invisible while the cookie made/loginalready 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.success(notnavigate:updateLanguageanswers withback(), same URL, so Inertia updates props without reporting a navigation).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.app.tsintoresources/js/language.ts.window.location.reload().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, andSyncUserfires.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 callingtoMail()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.