---
url: https://radioactive-labs.github.io/plutonium-core/reference/i18n.md
---
# Internationalization

Every string Plutonium renders comes from a locale file, and every label it derives from a key (an action, a scope, a filter, a kanban column, a wizard step, a field's placeholder) can be supplied by one. Nothing in a definition has to change for an app to translate its UI or reword the defaults.

## Where the strings live

Plutonium ships its own text under `config/locales/en/*.yml` in the gem, all under the `plutonium` namespace. Rails loads it at the lowest precedence:

1. The host application's `config/locales` wins over everything.
2. Package and portal engines' `config/locales`, which Rails loads automatically.
3. The gem's defaults.

So a portal can reword a Plutonium string for itself, and the app can override the portal.

To change a fixed string, copy its key into your own locale file. The gem's locale files are the catalogue:

```yaml
# config/locales/en.yml
en:
  plutonium:
    boolean:
      "true": "On"
      "false": "Off"
```

Pagination is the one surface with its own dictionary. The info sentence, the per-page sentence and the nav aria labels come from [Pagy's](https://ddnexus.github.io/pagy/resources/i18n) locale files, which cover about thirty-five languages. Override a Pagy key by adding a file to `Pagy::I18n.pathnames`, not through Rails I18n.

## Switching locale

Plutonium reads `I18n.locale` on every render and never sets it. Set the locale the way any Rails app does, typically an `around_action` in the portal's controller concern:

```ruby
module AdminPortal
  module Concerns
    module Controller
      extend ActiveSupport::Concern

      included do
        around_action :switch_locale
      end

      def switch_locale(&)
        I18n.with_locale(current_user&.locale || I18n.default_locale, &)
      end
    end
  end
end
```

The Pagy locale is synced to `I18n.locale` on each pagination render, so the same switch covers pagination.

## Models and attributes

Model and attribute names follow the Rails convention and already drive every label, column header, page title and flash:

```yaml
en:
  activerecord:
    models:
      blogging/post:
        one: "Post"
        other: "Posts"
    attributes:
      blogging/post:
        title: "Title"
        published_at: "Published"
```

The model key is `model_name.i18n_key`: `blogging/post` for `Blogging::Post`. When a model defines `one` and `other`, Plutonium uses them for the plural forms in page titles, empty states and pagination. Without them it falls back to English inflection.

## Fields: placeholder, hint, description

A definition never has to declare help text to make it translatable. When an input or display leaves a slot blank, Plutonium looks it up by convention:

```yaml
en:
  plutonium:
    fields:
      blogging/post:
        title:
          placeholder: "A short, descriptive title"
          hint: "Shown in search results"
        body:
          description: "Rendered as Markdown"
```

`placeholder` and `hint` apply to forms. `description` applies to displays. The lookup walks the model's ancestors like `human_attribute_name` does, so a key on `blogging/post` also serves an STI subclass.

For each slot the resolution order is:

1. An explicit option on `input` or `display`, then on `field`.
2. `plutonium.portals.<portal>.fields.<model>.<attr>.<slot>` when rendering inside a portal.
3. `plutonium.fields.<model>.<attr>.<slot>`.
4. `helpers.placeholder.<model>.<attr>`, the Rails `form_with` convention, for placeholders only.
5. Nothing.

An explicit option can still be a translation. Use the definition's class-level `t`, which returns a lazy value resolved on every render in the request's locale:

```ruby
class PostDefinition < Plutonium::Resource::Definition
  input :email, placeholder: t("forms.shared.email_placeholder")
  input :quantity, hint: t("forms.stock.hint", count: 3)
end
```

`t` is sugar over the proc form, which also works and has always been locale-safe:

```ruby
input :email, placeholder: -> { I18n.t("forms.shared.email_placeholder") }
```

Never call `I18n.t` directly in a class body. It runs once, at load time, in whatever locale happened to be active. Literal strings remain allowed and are used as they are.

The page-title setters take the same values. Pass a literal to fix it, or the lazy `t` to translate it per request:

```ruby
index_page_title t("blog.index.title")
index_page_description t("blog.index.description")
```

When a definition sets no title, the page falls back to the resource's translated model name.

## Derived labels

Actions, scopes, filters, kanban columns and wizard steps default to a humanized version of their key. Each has a convention key that takes precedence over that fallback, and a `label:` option that takes precedence over both. For an action backed by an interaction, the interaction's explicit `presents label:` also counts as declared and wins over the convention; only the class-name default yields to it.

```yaml
en:
  plutonium:
    actions:
      blogging/post:
        publish: "Publish now"
    scopes:
      blogging/post:
        drafts: "Drafts"
    filters:
      blogging/post:
        author: "Written by"
    kanban_columns:
      blogging/post:
        in_review: "In review"
    wizard_steps:
      onboarding_wizard:
        billing: "Billing details"
```

The model segment is the resource's `model_name.i18n_key`. For wizard steps it is the wizard's `model_name.i18n_key`.

Enum values shown as badges, and the value pills of a select filter, resolve the way Rails resolves enum attribute values, with a Plutonium key as an alternative:

```yaml
en:
  activerecord:
    attributes:
      blogging/post:
        status/draft: "Draft"
        status/published: "Live"
  plutonium:
    values:
      blogging/post:
        status:
          archived: "Archived"
```

## Portals

Every convention key has a portal-scoped variant under `plutonium.portals.<portal>`, where `<portal>` is the package namespace (`admin_portal`). It wins over the global key while rendering inside that portal, and the main app has no portal segment.

Portal packages are Rails engines, so their `config/locales` directory loads automatically. The portal generator scaffolds `packages/<portal>/config/locales/en.yml` with the prefix in place:

```yaml
# packages/admin_portal/config/locales/en.yml
en:
  plutonium:
    portals:
      admin_portal:
        fields:
          blogging/post:
            title:
              placeholder: "Internal working title"
        actions:
          blogging/post:
            publish: "Publish to site"
```

Feature packages get the same file for their models' names, attributes and field text. Keys without the portal prefix apply everywhere, and load order settles conflicts: the app's files beat a package's, and a package's beat the gem's.

Locale keys vary by portal, never by tenant. Tenant-specific wording is a custom I18n backend concern and is out of scope.

## JavaScript

The Stimulus controllers bundled with the gem read their strings from a JSON blob the layout renders once per page, in the current locale:

```html
<meta name="pu-i18n" content="{&quot;clipboard&quot;:{&quot;copied&quot;:&quot;Copied!&quot;},...}">
```

The blob is the `plutonium.js` subtree of the locale merged over the default locale, so overriding a key in YAML changes the bundled JavaScript's text without rebuilding it, and a partially translated locale falls back per key. Host code can read the same blob:

```js
window.Plutonium.t("plutonium.js.turbo_confirm.confirm")
window.Plutonium.t("plutonium.js.attachment_input.delete_all", { count: 3 })
```

Third-party widgets keep their own locale mechanisms. The blob carries optional pass-through settings under `plutonium.js.libraries`:

| Key | Passed to |
|---|---|
| `slim_select` | Slim Select `settings` (`placeholderText`, `searchText`, `searchPlaceholder`, `searchingText`) |
| `flatpickr.locale` | flatpickr's `locale` option. Loading the matching `l10n` bundle is the host's job. |
| `uppy.strings` | Uppy `locale.strings` |
| `intl_tel_input` | intl-tel-input `i18n` |

## Auth

Rodauth's own text (labels, buttons, flashes, email subjects) is translated by the [rodauth-i18n](https://github.com/janko/rodauth-i18n) gem, which Plutonium's Rodauth views pick up unchanged because they render through Rodauth's configuration methods. The mailer templates the Rodauth generator copies into your app are plain ERB and are yours to translate.

## Testing

Turn on missing-translation errors in the test environment so a rendered component with a missing key fails instead of printing its key:

```ruby
# config/environments/test.rb
config.i18n.raise_on_missing_translations = true
```

Convention lookups are exempt. They probe with `default: nil` and render nothing when a key is absent, which is the intended behaviour, so they never raise.

## Adding a language

For a second locale, provide:

1. A [rails-i18n](https://github.com/svenfuchs/rails-i18n) locale for Rails' own strings, dates and numbers.
2. A Pagy dictionary for pagination, which Pagy ships for most languages.
3. `rodauth-i18n` if you use Rodauth.
4. A `plutonium` namespace translated from the gem's `config/locales/en/*.yml`.
5. Your models, attributes and field text under the keys above.

Plutonium itself ships only `en`.
