Backend-Rendered Apps Setup Guide
This guide walks you through setting up Tolgee for backend-rendered applications. By the end, you'll have in-context editing working with your server-rendered translations.
The setup has three parts:
- Backend: Wrap translations with invisible characters in development mode
- Frontend: Initialize Tolgee JS to detect wrapped strings
- CLI: Run in watch mode to sync translation changes
Prerequisites
- A Tolgee project with an API key (create one at app.tolgee.io)
- Docker and Docker Compose (for running the CLI in watch mode)
Step 1: Implement invisible key wrapping
Your backend needs to append invisible characters to each translation that encode the translation key. This allows Tolgee JS to identify which key produced each piece of text.
The encoding works by converting the key metadata to binary and mapping each bit to an invisible Unicode character:
0→ Zero-Width Non-Joiner (\u200C)1→ Zero-Width Joiner (\u200D)
Here's a complete implementation in PHP. Adapt this to your backend language:
class InvisibleWrapper
{
const INVISIBLE_CHARACTERS = ["\u{200C}", "\u{200D}"];
const MESSAGE_END = "\x0A";
/**
* Wraps a translation with invisible encoded key data.
* Call this in development mode only.
*/
public function wrap($key, $namespace, $translation)
{
$data = json_encode([
'k' => $key,
'n' => $namespace ?: ''
]);
return $translation . $this->encodeToInvisible($data . self::MESSAGE_END);
}
private function encodeToInvisible($text)
{
$result = '';
$bytes = unpack('C*', $text);
foreach ($bytes as $byte) {
// Convert byte to 8-bit binary, then add separator bit
$binary = str_pad(decbin($byte), 8, '0', STR_PAD_LEFT) . '0';
for ($i = 0; $i < strlen($binary); $i++) {
$result .= self::INVISIBLE_CHARACTERS[(int)$binary[$i]];
}
}
return $result;
}
}
Using the wrapper
Only wrap translations in development mode. In production, serve translations without the invisible characters.
The translator class below supports ICU MessageFormat for plurals, select, and other advanced formatting. It requires PHP's intl extension, with a fallback to simple parameter replacement if the extension is unavailable.
class Translator
{
private $translations;
private $wrapper;
private $isDevelopment;
private $language;
public function __construct($language, $isDevelopment = false)
{
$this->language = $language;
$this->translations = $this->loadTranslations($language);
$this->wrapper = new InvisibleWrapper();
$this->isDevelopment = $isDevelopment;
}
public function t($key, $params = [], $namespace = null)
{
$translation = $this->translations[$key] ?? $key;
// Use ICU MessageFormat if we have parameters
if (!empty($params)) {
$formatted = $this->formatWithICU($translation, $params);
if ($formatted !== null) {
$translation = $formatted;
} else {
// Fallback to simple replacement for non-ICU strings or on error
$translation = $this->simpleReplace($translation, $params);
}
}
if ($this->isDevelopment) {
$translation = $this->wrapper->wrap($key, $namespace, $translation);
}
return $translation;
}
private function formatWithICU($message, $params)
{
if (!class_exists('MessageFormatter')) {
return null;
}
$formatter = MessageFormatter::create($this->language, $message);
if ($formatter === null) {
return null;
}
$result = $formatter->format($params);
return $result === false ? null : $result;
}
private function simpleReplace($message, $params)
{
foreach ($params as $key => $value) {
$message = str_replace('{' . $key . '}', (string)$value, $message);
}
return $message;
}
private function loadTranslations($language)
{
$file = __DIR__ . '/i18n/' . $language . '.json';
return file_exists($file)
? json_decode(file_get_contents($file), true)
: [];
}
}
The Translator class above uses PHP's MessageFormatter to support ICU MessageFormat - this enables advanced formatting like plurals. The MessageFormatter class requires the intl extension.
If you only need simple {placeholder} replacements, you can remove the formatWithICU method and skip the extension. However, Tolgee recommends the ICU format for its flexibility.
For Docker-based PHP applications, install the extension in your Dockerfile:
RUN apt-get update && apt-get install -y libicu-dev \
&& docker-php-ext-install intl
Namespaces
The namespace parameter is optional and used to organize translations into logical groups. If your Tolgee project uses namespaces (e.g., common, errors, dashboard), pass the namespace when calling the translator:
$translator->t('save_button', [], 'common'); // Key "save_button" in namespace "common"
$translator->t('not_found', [], 'errors'); // Key "not_found" in namespace "errors"
If you don't use namespaces in your project, you can omit the parameter or pass null. The wrapper will encode an empty string for the namespace.
Example usage
Here's how to use the Translator class in your templates or views:
// Development mode: translations include invisible characters for in-context editing
$translator = new Translator('en', isDevelopment: true);
// Simple translation
echo $translator->t('welcome');
// Output: "Welcome to our app" + invisible characters (dev only)
// With parameters (simple replacement)
echo $translator->t('greeting', ['name' => 'John']);
// Output: "Hello, John!" + invisible characters (dev only)
// With ICU plural formatting
// Translation: "{count, plural, one {# item} other {# items}}"
echo $translator->t('item_count', ['count' => 5]);
// Output: "5 items" + invisible characters (dev only)
// Production mode: plain translations without invisible characters
$translator = new Translator('en', isDevelopment: false);
echo $translator->t('welcome');
// Output: "Welcome to our app"
The output looks like plain text to users, but Tolgee JS can decode the invisible suffix to identify the key.
Do not use htmlspecialchars() or similar HTML encoding functions on wrapped translations. The invisible Unicode characters (Zero-Width Non-Joiner and Zero-Width Joiner) are safe for HTML output, but encoding them will corrupt the binary data and break Tolgee's key detection.
If you need HTML escaping for security, apply it to the raw translation before wrapping, or ensure your escaping function preserves these specific Unicode characters.
Step 2: Initialize Tolgee JS with the Observer
Add the Tolgee Web SDK to your HTML pages in development mode only. The ObserverPlugin watches the DOM for wrapped translations and enables in-context editing.
A simple way to determine development mode is to check if the TOLGEE_API_KEY environment variable is set - you should only have it configured in your development environment:
<?php $isDevelopment = !empty(getenv('TOLGEE_API_KEY')); ?>
Then conditionally include the Tolgee script. This ensures both the backend wrapping and frontend Tolgee JS are controlled by the same condition:
<?php if ($isDevelopment): ?>
<script src="https://cdn.jsdelivr.net/npm/@tolgee/web/dist/tolgee-web.development.umd.min.js"></script>
<script>
const { Tolgee, DevTools, ObserverPlugin } = window['@tolgee/web'];
Tolgee()
.use(DevTools())
.use(ObserverPlugin())
.init({
language: 'en',
apiUrl: 'https://app.tolgee.io',
apiKey: '<?= getenv('TOLGEE_API_KEY') ?>',
observerOptions: { fullKeyEncode: true }
})
.run();
</script>
<?php endif; ?>
Key configuration:
| Option | Description |
|---|---|
apiUrl | URL to your Tolgee instance. Use https://app.tolgee.io for Tolgee Cloud, or your self-hosted instance URL (e.g., https://tolgee.your-company.com). |
apiKey | Your project API key with appropriate scopes for in-context editing. |
observerOptions.fullKeyEncode | Must be true to detect invisible-wrapped strings. |
The API key in client-side JavaScript is visible to anyone viewing your page source. Follow these practices:
- Only set
TOLGEE_API_KEYin development - don't configure it in production environments - Never commit API keys to version control - use
.envfiles and add them to.gitignore - Use scoped keys - create API keys with minimal permissions (only what's needed for in-context editing)
Once Tolgee initializes, hold Alt (Windows/Linux) or Option (Mac) and click on any translated text to open the translation editor.
Both the backend wrapping and frontend Tolgee JS must be enabled or disabled together. If they get out of sync (e.g., backend wraps but frontend doesn't have Tolgee JS), the invisible characters will remain in the HTML and can cause issues when users copy/paste text.
Step 3: Keep translations in sync
The Tolgee CLI can pull translations to local files and watch for changes. This keeps your filesystem in sync with the platform.
CLI configuration (.tolgeerc.json in your project root):
{
"$schema": "https://docs.tolgee.io/cli-schema.json",
"projectId": 12345,
"format": "JSON_TOLGEE",
"languages": ["en", "cs", "fr", "de"],
"pull": {
"path": "./i18n"
}
}
| Option | Description |
|---|---|
projectId | Your Tolgee project ID. Find it in the Tolgee platform URL: app.tolgee.io/projects/{projectId} |
format | Output format. JSON_TOLGEE creates simple {"key": "translation"} files. Other options include JSON_ICU, XLIFF, etc. See CLI formats. |
languages | Languages to pull. If omitted, all project languages are pulled. |
pull.path | Where to save translation files. Path is relative to the config file location. |
Run directly:
tolgee pull --watch
Or with Docker:
docker run -v .:/app -e TOLGEE_API_KEY=your-key tolgee/cli pull --watch
Or as a Docker Compose service alongside your app:
When using Docker, paths work differently because the CLI runs inside the container. The recommended approach is to mount only the translation directory and use command-line arguments instead of relative paths in the config:
services:
tolgee-cli:
image: tolgee/cli
volumes:
- ./i18n:/app/i18n # Mount only the translations directory
- ./.tolgeerc.json:/app/.tolgeerc.json:ro # Mount config as read-only
environment:
- TOLGEE_API_KEY=${TOLGEE_API_KEY}
command: pull --path /app/i18n --watch
Alternatively, use absolute paths in your config when running in Docker:
{
"projectId": <your project ID in Tolgee platform>,
"format": "JSON_TOLGEE",
"pull": {
"path": "/app/i18n"
}
}
The CLI watches for changes on the platform and automatically updates your local files.
With the CLI running in watch mode, any translation you edit through in-context editing will be pulled to your local files within seconds. Refresh the page to see the updated translation rendered by your backend.
Complete example
For a working reference implementation, see the PHP example repository. It demonstrates all the concepts from this guide in a minimal PHP application.
Next steps
Once you have the basic setup working, see In-Context Editing on Production to learn how to enable translation editing for your team on production environments without exposing API keys to end users.