Plugin Localization
This page is for plugin developers who want to ship translated strings. It is not for site administrators.
ChurchCRM has a 45-language core translation workflow managed through POeditor. Community plugins do not participate in that workflow. Trying to add your plugin's strings to POeditor would force the ChurchCRM maintainers to translate your plugin for every language they support, which is not a burden we want to place on the volunteer translation community.
Instead, community plugins ship their own translations inside the plugin directory, and ChurchCRM loads them at boot. This page explains exactly how that works.
Directory layout
Add a locale/ subdirectory to your plugin:
plugins/community/my-plugin/
├── plugin.json
├── src/
│ └── MyPluginPlugin.php
└── locale/
├── textdomain/ # PHP gettext (.mo) files
│ ├── en_US/LC_MESSAGES/my-plugin.mo
│ ├── de_DE/LC_MESSAGES/my-plugin.mo
│ └── es_ES/LC_MESSAGES/my-plugin.mo
└── i18n/ # JavaScript i18next (flat k/v JSON)
├── en_US.json
├── de_DE.json
└── es_ES.json
Both subdirectories are optional. If your plugin only has PHP strings,
ship just textdomain/. If it only has JS strings, ship just i18n/.
If it has no strings at all, omit locale/ entirely.
Rule of thumb: always ship an en_US file in each directory you
use. That gives users on unsupported locales a usable fallback.
PHP strings (gettext)
At boot, ChurchCRM calls
PluginLocalization::bindPhpDomains() which walks every loaded plugin
and, for each one with a locale/textdomain/ directory, calls:
bindtextdomain('my-plugin', '{plugin-path}/locale/textdomain');
bind_textdomain_codeset('my-plugin', 'UTF-8');
The gettext textdomain name is your plugin id. This keeps your
strings fully isolated from the core messages domain and from every
other plugin.
In your plugin code, always use dgettext() — never plain
gettext() or _(), because those go to the core messages domain
and your strings will never be found:
// ✅ CORRECT — translates from locale/textdomain/.../my-plugin.mo
echo dgettext('my-plugin', 'Welcome to My Plugin');
// ❌ WRONG — looks up the string in the core messages domain
echo gettext('Welcome to My Plugin');
Building .mo files
Use the standard gettext toolchain. Your source .po file lives
wherever you want inside your plugin's source repo — only the
compiled .mo has to be shipped in the release zip.
Extract strings from your PHP source:
xgettext \
--language=PHP \
--keyword=dgettext:2 \
--from-code=UTF-8 \
--output=locale/my-plugin.pot \
src/**/*.php
Compile a translated .po to .mo:
msgfmt -o locale/textdomain/de_DE/LC_MESSAGES/my-plugin.mo locale/de_DE.po
Note the
--keyword=dgettext:2. The defaultxgettextsettings extractgettext()/_()calls. Plugin strings usedgettext(), whichxgettextdoesn't scan unless you tell it to.
JavaScript strings (i18next, no core changes needed)
Ship a flat key → string JSON file per locale at
locale/i18n/{locale}.json. The loader does three things:
- Rejects files larger than 512 KB.
- Falls back to
en_US.jsonwhen the user's locale file is missing. - Embeds the resulting map in
window.CRM.plugins.{id}.i18n.
Example file:
{
"myplugin.welcome": "Willkommen bei My Plugin",
"myplugin.save": "Speichern",
"myplugin.cancel": "Abbrechen"
}
Consuming the strings
Option 1 — plain lookup (simplest):
const t = (key) =>
window.CRM?.plugins?.["my-plugin"]?.i18n?.[key] ?? key;
document.getElementById("title").textContent = t("myplugin.welcome");
Option 2 — register with i18next as a namespace (if your page already uses i18next):
i18next.addResourceBundle(
window.CRM.shortLocale,
"my-plugin",
window.CRM.plugins["my-plugin"].i18n || {},
true,
true
);
i18next.t("myplugin.welcome", { ns: "my-plugin" });
Rules the loader enforces
- Flat maps only. Nested JSON objects are silently dropped. Use
prefixed keys (
myplugin.welcome) instead of namespacing through nested structures. - 512 KB cap. Keep individual locale files small; split them if needed. A warning is logged if a file is skipped.
- UTF-8. Every
.momust be UTF-8; every JSON file must be UTF-8 without BOM. - Namespace your keys. Prefix every key with your plugin id so you never collide with another plugin or with core strings.
- Plugin ids are kebab-case. The loader refuses to bind a
textdomain for any id that isn't kebab-case (matching
^[a-z0-9][a-z0-9-]*$) or that equals the reserved namemessages.
Testing your translations
- Install your plugin into a local CRM (
POST /plugins/api/plugins/install), enable it, and switch the admin user's UI locale to a language you shipped a translation for. - Load a page that uses a translated string. Confirm the translation renders.
- Switch to a language you did not translate. Confirm the
en_USfallback renders (this proves your fallback file is correct). - Temporarily rename your
locale/i18n/en_US.jsonto confirm that the loader then shows the raw key — which is what your users will see if you ship a broken release.
FAQ
Can I use POeditor for my plugin anyway?
No — POeditor only covers the core messages textdomain and the core
locale/i18n/*.json files. Community plugins live in a different
textdomain, so strings submitted to POeditor would never be loaded at
runtime.
Do I have to translate into every language ChurchCRM supports?
No. Ship en_US plus whatever you want to ship. Missing locales fall
back to en_US, and users on unsupported locales still see a working
interface.
My JSON file has nested objects and everything disappeared. Why?
The loader intentionally rejects nested structures — only flat
string → string maps are accepted. Flatten your keys
(group.section.key) and re-ship.
Where is the code that loads these files?
src/ChurchCRM/Plugin/PluginLocalization.php— the loader itself.src/ChurchCRM/Plugin/PluginManager.php— callsbindPhpDomains()inloadActivePlugins()and embeds JS resources ingetPluginsClientConfig()..agents/skills/churchcrm/plugin-development.md → Plugin Localization— the canonical developer skill.