Translate sidebar tab labels, group labels, and tooltips inside your PhoenixKit module.
This guide shows how each PhoenixKit module owns its own Gettext backend, ships its own .po files, and registers admin/settings/dashboard tabs with gettext_backend: so labels translate at render time according to the user's locale. Applies to every module that exposes UI — both new modules being authored from day 1 and existing modules being uplifted to the PhoenixKit release that introduces the gettext_backend / gettext_domain API. Confirm the exact minimum version against the PhoenixKit CHANGELOG.
Quick Start
# 1. Create the module's Gettext backend
defmodule PhoenixKitProjects.Gettext do
use Gettext.Backend, otp_app: :phoenix_kit_projects
end
# 2. In mix.exs
def application, do: [extra_applications: [:logger, :gettext]]
defp deps do
[
# Use the version that introduces the gettext_backend API
# — check PhoenixKit's CHANGELOG for the exact minimum.
{:phoenix_kit, "~> X.Y"},
{:gettext, "~> 1.0"}
]
end
# 3. Register tabs with gettext_backend
@impl PhoenixKit.Module
def admin_tabs do
[
Tab.new!(
id: :admin_projects,
label: "Projects",
icon: "hero-folder",
path: "projects",
priority: 400,
level: :admin,
permission: "projects",
group: :admin_modules,
gettext_backend: PhoenixKitProjects.Gettext
)
]
end
# 4. Extract msgids and fill translations
# $ mix gettext.extract --merge
# Edit priv/gettext/ru/LC_MESSAGES/default.po
# msgid "Projects"
# msgstr "Проекты"What core gives you
Starting with the PhoenixKit release that introduces this API (see CHANGELOG for the exact version), PhoenixKit.Dashboard.Tab and PhoenixKit.Dashboard.Group accept two optional fields:
| Field | Type | Default | Purpose |
|---|---|---|---|
gettext_backend | module() or nil | nil | Module that owns the Gettext catalogue for this tab/group. nil keeps the raw label. |
gettext_domain | String.t() | "default" | Gettext domain to look the msgid up in. |
Sidebar / AdminSidebar / TabItem components automatically route every label and tooltip render through:
Tab.localized_label/1Tab.localized_tooltip/1Group.localized_label/1
Each helper:
- Returns
nilif the underlying field (label/tooltip) isnil— divider tabs and unlabeled groups stay safe. - Returns the raw string if
gettext_backendisnil— backwards compatible. - Otherwise calls
Gettext.dgettext(backend, domain, msgid)against the process locale of the LiveView. Locale is set per request by the parent app's locale plug or on-mount hook. Modules must not set the locale themselves.
Why each module owns its own backend
When a module ships as a separate Hex package, it cannot rely on the parent application's PhoenixKitWeb.Gettext — that backend belongs to the core library, not to your package. Each module ships its own .po files in its own priv/gettext/ and registers translations with gettext_backend: PhoenixKit<X>.Gettext. The parent app sets the user's locale once per request; every module's backend then independently looks up its own msgids in its own catalogue.
This also matches the dynamic_children/2 callback contract: arity-2 dynamic-children functions already receive the current locale, but using gettext_backend: on returned %Tab{} structs is more declarative and avoids manual Gettext.put_locale/2 juggling.
Setup checklist
| # | Step | Where |
|---|---|---|
| 1 | Bump the phoenix_kit dep to the release that ships the gettext_backend API (see PhoenixKit CHANGELOG) | mix.exs |
| 2 | Add :gettext to extra_applications and to deps | mix.exs |
| 3 | Add priv to package: [files: ...] so .po files ship to Hex | mix.exs |
| 4 | Create the module's own Gettext backend | lib/phoenix_kit_<x>/gettext.ex |
| 5 | Replace every use Gettext, backend: PhoenixKitWeb.Gettext with the module's own backend | grep -rl "PhoenixKitWeb.Gettext" lib/ |
| 6 | Set gettext_backend: (and gettext_domain: if needed) on every %Tab{} and %Group{} registration | admin_tabs/0, route module |
| 7 | Maintain priv/gettext/default.pot and priv/gettext/<locale>/LC_MESSAGES/default.po manually (mix gettext.extract does not see Tab.new!(label: "…") plain strings) | priv/gettext/ |
| 8 | Fill translations for each target locale (en is 1:1) | priv/gettext/<locale>/LC_MESSAGES/default.po |
| 9 | Add conditional skip in test/test_helper.exs so CI building against a pre-API phoenix_kit doesn't fail (see § Conditional CI skip) | test/test_helper.exs |
| 10 | Add a smoke test (see § Test pattern) | test/ |
Do NOT bump
@versionor write aCHANGELOG.mdentry. Both are maintainer-owned across core and everyphoenix_kit_<x>child module — the maintainer derives the version and the CHANGELOG entry from your commit messages at release time. Write descriptive commit messages; that's the contribution. See § Version and CHANGELOG ownership.
Step-by-step setup
A. Create the Gettext backend
# lib/phoenix_kit_<x>/gettext.ex
defmodule PhoenixKit<X>.Gettext do
@moduledoc """
Gettext backend for phoenix_kit_<x>.
Locale is set per-request by the parent application; this module's only
responsibility is owning the catalogues under `priv/gettext/`.
"""
use Gettext.Backend, otp_app: :phoenix_kit_<x>
enduse Gettext.Backend, otp_app: ... is the Gettext 0.26+ form. The older use Gettext, otp_app: ... style is deprecated; do not use it.
B. mix.exs
def application do
[
extra_applications: [:logger, :gettext] # :gettext is required at runtime
]
end
defp deps do
[
# Use the version that introduces the gettext_backend API
# — check PhoenixKit's CHANGELOG for the exact minimum.
{:phoenix_kit, "~> X.Y"},
{:gettext, "~> 1.0"}
]
endC. Switch existing use Gettext calls
Find every file in your module that currently uses the parent app's backend:
grep -rl "PhoenixKitWeb.Gettext" lib/
Replace:
# Before
use Gettext, backend: PhoenixKitWeb.Gettext
# After
use Gettext, backend: PhoenixKit<X>.GettextThis is mandatory before hex.publish — your published package must not reference the parent app's backend.
D. Wire your tabs and groups
Every %Tab{} and %Group{} registered by your module's admin_tabs/0, settings_tabs/0, user_dashboard_tabs/0, or dynamic_children/2 callback must carry the backend:
@impl PhoenixKit.Module
def admin_tabs do
[
Tab.new!(
id: :admin_projects,
label: "Projects",
icon: "hero-folder",
path: "projects",
priority: 400,
level: :admin,
permission: "projects",
group: :admin_modules,
gettext_backend: PhoenixKit<X>.Gettext,
gettext_domain: "default" # optional — "default" is the default
),
Tab.new!(
id: :admin_projects_new,
label: "New Project",
path: "projects/new",
priority: 410,
level: :admin,
permission: "projects",
parent: :admin_projects,
gettext_backend: PhoenixKit<X>.Gettext
)
]
endGroups, when your module contributes them:
%Group{
id: :projects_section,
label: "Project management",
priority: 400,
collapsible: true,
gettext_backend: PhoenixKit<X>.Gettext
}E. dynamic_children/2 — locale-aware children
If your tab uses dynamic_children: to render child tabs at runtime (e.g. one tab per project), implement the arity-2 form. Children only need the backend set on items whose labels are msgids; user-supplied data stays raw:
%Tab{
id: :admin_projects,
label: "Projects",
# ... other fields ...
dynamic_children: fn _scope, _locale ->
PhoenixKit.RepoHelper.repo().all(Project)
|> Enum.map(fn project ->
%Tab{
id: :"admin_project_#{project.id}",
label: project.name, # raw user-supplied — no translation needed
path: "projects/#{project.id}",
parent: :admin_projects,
level: :admin,
permission: "projects"
# gettext_backend NOT set — project names are user data, not msgids
}
end)
end
}Rule of thumb: set
gettext_backend:only whenlabel/tooltipis a fixed English msgid that lives in your.pofiles. User-supplied content (project names, document titles, customer names) stays raw.
F. Dividers and group headers
Tab.divider/1 and Tab.group_header/1 accept the same options:
Tab.divider(
priority: 150,
label: "Account",
gettext_backend: PhoenixKit<X>.Gettext
)
Tab.group_header(
id: :reports_header,
label: "Reports",
priority: 500,
gettext_backend: PhoenixKit<X>.Gettext
)G. Tooltips
Tooltips translate via the same backend automatically — set tooltip: to the msgid alongside gettext_backend: and the sidebar's title= attribute will render the translated text:
Tab.new!(
id: :admin_projects,
label: "Projects",
tooltip: "Manage all projects", # msgid for tooltip translation
path: "projects",
gettext_backend: PhoenixKit<X>.Gettext
)H. Extract msgids and fill translations
# Generate / update the .pot (template)
mix gettext.extract
# Merge new msgids into each locale's .po
mix gettext.merge priv/gettext --locale en
mix gettext.merge priv/gettext --locale ru
mix gettext.merge priv/gettext --locale et
Edit priv/gettext/<locale>/LC_MESSAGES/default.po:
#: lib/phoenix_kit_<x>/<x>.ex
msgid "Projects"
msgstr "Проекты"
#: lib/phoenix_kit_<x>/<x>.ex
msgid "New Project"
msgstr "Новый проект"
#: lib/phoenix_kit_<x>/<x>.ex
msgid "Manage all projects"
msgstr "Управление всеми проектами"For en: msgstr should equal msgid (gettext's "no translation needed" convention; without it, gettext returns the empty string for en and your label disappears).
Greenfield module example
Full minimal module with i18n built in from day 1:
# lib/phoenix_kit_<x>/<x>.ex
defmodule PhoenixKit<X> do
use PhoenixKit.Module
alias PhoenixKit.Dashboard.{Tab, Group}
@impl PhoenixKit.Module
def module_key, do: "<x>"
@impl PhoenixKit.Module
def module_name, do: "<X> Module"
@impl PhoenixKit.Module
def permission_metadata do
%{
key: "<x>",
label: "<X>",
icon: "hero-folder",
description: "<short description>"
}
end
@impl PhoenixKit.Module
def admin_tabs do
[
Tab.new!(
id: :admin_<x>,
label: "<X>",
icon: "hero-folder",
path: "<x>",
priority: 400,
level: :admin,
permission: "<x>",
group: :admin_modules,
gettext_backend: PhoenixKit<X>.Gettext
)
]
end
end# lib/phoenix_kit_<x>/gettext.ex
defmodule PhoenixKit<X>.Gettext do
@moduledoc "Gettext backend for phoenix_kit_<x>."
use Gettext.Backend, otp_app: :phoenix_kit_<x>
endRetrofitting an existing module
For each existing phoenix_kit_<x> module being uplifted to the new API:
- [ ]
mix.exs— confirmphoenix_kitdep constraint admits the release that shipsgettext_backend(typically~> 1.7is wide enough; pin tighter if needed) - [ ]
mix.exs—:gettextis inextra_applicationsAND{:gettext, "~> 1.0"}is indeps [ ]
mix.exs—package: [files: ~w(lib priv …)]includespriv(verify withmix hex.build+tar -tzf | grep priv/gettext)- [ ] Create
lib/phoenix_kit_<x>/gettext.exwithuse Gettext.Backend, otp_app: :phoenix_kit_<x> - [ ]
grep -rl "PhoenixKitWeb.Gettext" lib/returns zero results - [ ] Maintain
priv/gettext/default.potandpriv/gettext/{en,ru,et,…}/LC_MESSAGES/default.pomanually —mix gettext.extractdoes not see plainTab.new!(label: "…")strings (nodgettextmacro call).en/default.pohasmsgstr=msgidfor every entry;ru/etare filled - [ ] Every
Tab.new!,%Tab{},Tab.divider/1,Tab.group_header/1,%Group{},Group.new/1in your module setsgettext_backend: - [ ]
dynamic_children:callbacks return tabs withgettext_backend:set (when labels are msgids, not user data) - [ ]
test/test_helper.exshas the conditional:requires_phoenix_kit_i18n_apiskip (see § Conditional CI skip) - [ ] Smoke test in
test/phoenix_kit/<x>/i18n_test.exscarries@moduletag :requires_phoenix_kit_i18n_apiand passes locally withphoenix_kitresolved to a release that ships the API - [ ]
mix testclean (locally with API; on CI without API, i18n tests excluded automatically — also clean) - [ ] Do NOT bump
@versionor write a CHANGELOG entry — both are maintainer-owned (see § Version and CHANGELOG ownership). Write a descriptive commit message instead. - [ ] Commit on a feature branch, push, open PR.
Hex package shape
mix.exs package files: must include priv. The directory is otherwise excluded from the Hex tarball, so the new .po files would not ship and Gettext.dgettext/3 would silently return raw msgids in production for every consumer that installed from Hex.
defp package do
[
licenses: ["MIT"],
links: %{"GitHub" => @source_url},
files: ~w(lib priv .formatter.exs mix.exs README.md CHANGELOG.md LICENSE)
# ^^^^ this
]
endVerify locally with:
mix hex.build
tar -tzf phoenix_kit_<x>-*.tar | grep priv/gettext
# should list every .po and .pot file
Conditional CI skip
The gettext_backend API was introduced by PR #522 on phoenix_kit core. Until the consumer's phoenix_kit dep resolves to a published release that includes it, PhoenixKit.Dashboard.Tab.localized_label/1 does not exist and the i18n smoke test would raise UndefinedFunctionError. To keep CI green on consumers who haven't yet upgraded, gate the smoke test with a :requires_phoenix_kit_i18n_api tag and detect availability in test_helper.exs:
# test/test_helper.exs
require Logger
if Code.ensure_loaded?(PhoenixKit.Dashboard.Tab) and
function_exported?(PhoenixKit.Dashboard.Tab, :localized_label, 1) do
ExUnit.start()
else
Logger.info(
"[test_helper] PhoenixKit.Dashboard.Tab.localized_label/1 not available — " <>
"i18n tests excluded. They will run automatically once `phoenix_kit` is " <>
"upgraded to a release that ships the gettext_backend API."
)
ExUnit.start(exclude: [:requires_phoenix_kit_i18n_api])
end# test/phoenix_kit/<x>/i18n_test.exs
defmodule PhoenixKit.<X>.I18nTest do
use ExUnit.Case, async: false
@moduletag :requires_phoenix_kit_i18n_api
# ...
endCode.ensure_loaded?/1 is load-bearing — without it, function_exported?/3 returns false if the Tab module hasn't been loaded yet at helper-init time, and the i18n tests get excluded even when the API is available.
Version and CHANGELOG ownership
The package version (@version in mix.exs) and the CHANGELOG.md are owned by the maintainer. This applies uniformly to phoenix_kit core and every phoenix_kit_<x> child module — even the modules whose forks you maintain locally. The maintainer derives the version bump (patch / minor / major) and the CHANGELOG entry from the commit messages on the merged PR; agents and contributors do not touch either.
What contributors do:
- Write descriptive commit messages that read like a CHANGELOG entry — what changed, why, links to related issues / PRs / dependencies. The first line is the title; the body explains the rest.
- Leave
@versionexactly as it stands at HEAD. - Leave
CHANGELOG.mdexactly as it stands at HEAD.
What contributors do NOT:
- Edit
@versioninmix.exs(even a single patch bump). - Add or modify entries in
CHANGELOG.md.
If you find yourself wanting to bump the version "for visibility", stop and improve the commit message instead. The maintainer reads commit bodies when assembling the release notes.
This rule is intentional: it keeps version bumps centralized so the maintainer can squash a feature PR and a follow-up fix into a single release without coordinating two version-line edits, and it removes a frequent source of merge conflicts on mix.exs @version and CHANGELOG.md first-entry header.
Test pattern
A single smoke test per module is sufficient — core's tests already cover the localization machinery itself:
defmodule PhoenixKit<X>.I18nSmokeTest do
use ExUnit.Case, async: false
# Excluded by `test/test_helper.exs` when running against a `phoenix_kit`
# release that pre-dates the `gettext_backend` API. Once the consumer
# upgrades, the helper detects it and these tests run automatically.
@moduletag :requires_phoenix_kit_i18n_api
alias PhoenixKit.Dashboard.Tab
setup do
original = Gettext.get_locale(PhoenixKit<X>.Gettext)
on_exit(fn -> Gettext.put_locale(PhoenixKit<X>.Gettext, original) end)
:ok
end
test "admin tab labels translate to ru" do
Gettext.put_locale(PhoenixKit<X>.Gettext, "ru")
[tab | _] = PhoenixKit<X>.admin_tabs()
# Replace with the actual translation you put in ru/default.po
assert Tab.localized_label(tab) == "Проекты"
end
test "admin tab labels fall back to msgid for an unknown locale" do
Gettext.put_locale(PhoenixKit<X>.Gettext, "xx")
[tab | _] = PhoenixKit<X>.admin_tabs()
assert Tab.localized_label(tab) == tab.label
end
endasync: false is required because Gettext.put_locale/2 mutates the calling process's process dictionary; on_exit restores it cleanly. Pair this module with the conditional helper from § Conditional CI skip.
Common pitfalls
❌ Do NOT call Gettext.put_locale/2 from inside your module — locale is a request-scoped concern owned by the parent app.
❌ Do NOT translate before passing to PhoenixKit core APIs that persist data. For example, Tab.label is the source of the row label that Permissions.register_custom_key/2 writes to the database. Translating it before registration would corrupt the canonical key store. Pass the raw msgid; rendering localizes.
❌ Do NOT invent a custom domain unless you actually have multiple. "default" is the convention; switch to a domain-per-area only when one default.po becomes too noisy.
❌ Do NOT keep use Gettext, backend: PhoenixKitWeb.Gettext in published code. That backend belongs to PhoenixKit core and won't be mounted in every consumer app the same way.
❌ Do NOT set gettext_backend: on dynamically-generated user-data labels (project names, document titles). These are not msgids; gettext would return the raw string anyway, but the field still wastes a Gettext call per render.
❌ Do NOT ship without mix gettext.extract --merge — stale .pot means newly added msgids never reach .po and translators have nothing to translate.
❌ Do NOT pattern-match on gettext_backend / gettext_domain from a generic Tab handler. The library's resolvers (Tab.localized_label/1 etc.) deliberately use Map.get/2 so old-shape Tab structs cached in ETS or :persistent_term from before the upgrade — missing both new keys — flow through gracefully instead of raising KeyError. If you write your own iteration over PhoenixKit.ModuleRegistry.all_admin_tabs/0, prefer Tab.localized_label/1 over reaching into the struct directly. This matters during the rolling-upgrade window where a parent app's Phoenix server keeps running across the phoenix_kit upgrade.
Where this fits in the rollout
| Phase | Repo | What |
|---|---|---|
| 1 ✅ | phoenix_kit (core) | gettext_backend / gettext_domain API merged on dev. Maintainer ships the API in an upcoming release — see PhoenixKit CHANGELOG for the version. |
| 2 ⏳ | each phoenix_kit_<x> package | Apply this guide. Pilot: phoenix_kit_projects |
| 3 ⏳ | parent apps | Drop ETS-patching hacks; pass gettext_backend: to their own tabs |
Phase 2 modules are independent of one another — they can be migrated in parallel by different developers, one PR per repo. Phase 3 happens at any point after phase 1 ships, regardless of phase 2 progress.
Reference
PhoenixKit.Dashboard.Tab—localized_label/1,localized_tooltip/1,divider/1,group_header/1PhoenixKit.Dashboard.Group—localized_label/1- Gettext docs —
dgettext,Gettext.Backend,mix gettext.extract,mix gettext.merge