vTrans bundles several text-processing APIs behind a single interface, stores the results in the database, and provides backend pages for testing, analysis, and maintenance.
The primary use case is translation. With LLM-based providers (the OpenAI provider or ai_platform), it can also be used for other scenarios where the source and target language are the same, for example summarizing, rephrasing, or editing content.
Note: vTrans is a release candidate for version 1.0. Production use is recommended only after appropriate testing and under close monitoring.
Install it via the REDAXO installer or copy it manually to redaxo/src/addons/vtrans and activate it in the backend.
Requirements:
- REDAXO >= 5.17.0
- PHP >= 8.2
- In the backend, open
vTrans -> Connectionsand create a connection. - Select a provider (for example
deepl-api-free-v2,openai, orgoogle-translate-basic-v2). - Enter the API key, API URL, and any additional parameters.
- Optionally mark it as the default and/or enable it for the Playground.
- Open
vTrans -> Playgroundand test it. - Check out the template example and try it out. That's the quickest way to understand the concept.
Example for a DeepL Free connection:
- Key:
deepl_free - Label:
DeepL Free - Provider:
deepl-api-free-v2 - API URL:
https://api-free.deepl.com/v2/translate - API Key:
YOUR_DEEPL_KEY
Notes:
- Free keys belong to the free API URL
https://api-free.deepl.com/v2/translate. - The default connection is used automatically when no
connectionvalue is passed in the request.
- Access several providers through one unified API
- Manage connections centrally in the backend
- Test requests manually in the Playground
- DB cache is monitored via string hash, connection, language, and format
- Stable keys (optional) for reusable content
- Search, filter, review, and edit stored records under
Data - Keep provider metadata such as usage or rate limits as raw data
- Market leader with very good quality for common languages
deepl-api-free-v2deepl-api-pro-v2- Supports
contextandcustomInstructions
Note: Unfortunately, DeepL's free plan (500k characters/month) no longer exists. For testing, or later as a low-cost alternative, I've set up a DeepL-compatible server. Get in touch if you're interested!
- Good to very good translation quality
amazon-translate-v1- API-key-/credential-based depending on the provider implementation
- Good to very good translation quality
google-translate-basic-v2- API-key-based
- No prompt options
- Very good translation quality
- Service-account / OAuth-based
- No prompt options
- Good quality - sufficient for most use cases
- Open source - can also be self-hosted
libretranslate-v1- Optional
apiKey - No prompt options
- Simple, rather technical translation
mymemory-v2- Endpoint-based (default:
https://api.mymemory.translated.net/get) - Optional
apiKeyandemail - No prompt options
- Flexible depending on the model
openai- Freely configurable endpoints and parameters
- Supports
contextandcustomInstructions; system prompt with placeholders, see System prompt
ai_platform- Delegates to a text profile of the ai_platform addon instead of calling an LLM endpoint directly
- Provider, model, API key, temperature and token limits are all managed in the ai_platform profile — the vTrans connection only selects the profile
- The prompt comes from the vTrans connection and works exactly as for
openai, see System prompt. SupportscontextandcustomInstructions - The ai_platform profile's own
system_promptis deliberately not used: vTrans cannot see it, so changing it would not refresh cached translations. The connection prompt is part of the cache hash - The connection has no timeout of its own: the HTTP call is made by ai_platform
- Requires the
ai_platformaddon; without it the provider is inert (empty profile select, clear error on use)
- Useful during development
- Generates simple test output to verify the functionality
- Local only - no API - no costs
The costs of the different providers vary widely and usually consist of a monthly base fee (subscription) and costs per 1 million characters. There are also often free or included quotas. This is something everyone needs to compare for themselves. LibreTranslate can also be self-hosted on suitable hardware. For a large website, you should expect around 20–50 EUR per language (of course only a rough estimate).
Configuration is done via the backend page Connections. There, connections are defined. Depending on the interface, the corresponding details are stored:
- Key (identification)
- Label (name)
- Provider / API (provider / interface)
- Debug flag
- Timeout
- Max. characters (a guideline, see below)
- Playground flag (available in the Playground)
- Various provider-specific parameters
Notes:
- The default connection is used automatically when no individual
Connectionis defined in the request. - The default connection and availability in the Playground can be switched quickly in the Connections overview.
Leave the System-Prompt field empty and vTrans sends its default prompt, which the form shows greyed out:
You are a professional translation engine.
Translate from {source_lang_name} to {target_lang_name}.
Return only the translated text, without explanations, quotes, markdown fences, or extra comments.
A prompt of your own replaces it entirely. These placeholders are filled in per request:
| Placeholder | Example |
|---|---|
{source_lang} |
DE (auto when detecting) |
{target_lang} |
EN-GB |
{source_lang_name} |
German |
{target_lang_name} |
English – British |
Name the target language in your prompt, preferably as {target_lang_name} — models handle names more reliably than codes. The form warns on save when neither target placeholder is present.
vTrans always appends the following, whatever the prompt says:
- The format rule — plain text, or for HTML: keep the markup and the
<vtrans-ph>placeholders, which vTrans needs to restore excluded regions. contextof the request, asContext:.customInstructionsof the request, as a list.
The prompt is part of the cache hash; changing it refreshes cached translations.
Requests can be tested manually here.
- Connection
- Source and target language
- Format (
textorhtml) - Text
- Optional key
- Additional
contextandcustomInstructionswhere supported
If a key is set, vTrans works with a stable dataset per model and target language.
- If an entry with the same hash already exists, it is reused.
- If the content has changed, the existing key-based record is updated.
- Without a key, only the normal cache based on hash, connection, language, and format is used.
A keyed record is identified by key, source language, target language, connection and format.
The same key can therefore exist as text and as html side by side without one overwriting
the other.
Max. characters is a guideline, not a limit. When a request exceeds it, vTrans records
that in the REDAXO system log (once per connection and request) and sends the request anyway.
That keeps the cost visible without breaking a site whose articles are simply longer than the
configured value.
vTrans uses the namespace FriendsOfRedaxo\VTrans.
use FriendsOfRedaxo\VTrans\VTrans; // Namespace for the VTrans class
$translated = VTrans::translate(
'<p>Hello world</p>', // content to translate
'en', // source language (also 'auto' possible)
'de', // target language
'html', // format (text or html)
'homepage_hero', // optional key
[ // additional optional parameters
'connection' => 'deepl_free', // connection (otherwise the default connection is used)
'context' => 'Marketing headline', // additional context (if supported)
'customInstructions' => [ // additional instructions (if supported)
'Use formal tone.',
'Keep HTML tags unchanged.',
],
]
);
echo $translated;
// Optional debug output
$meta = VTrans::getLastResultMeta();
$data = VTrans::getLastResultData();
dump($meta);
dump($data);Supported request options include:
connection: key of a saved connection (recommended)context: additional context for supported providerscustomInstructions: array or multiline string with additional instructionsdebug: enables provider debug datacache: boolean (trueby default). Withfalse, DB cache lookup and persistence are skipped.throwOnError: boolean. Overrides the default error behaviour (see below).
When a provider call fails — exhausted quota, timeout, wrong API key — the behaviour depends on the context:
- Frontend: no exception is thrown.
translate()returns the untranslated source text so the page stays readable. The error is written to the REDAXO system log (System > Log). - Backend and CLI: the exception propagates as before, so the Playground still displays it unchanged.
throwOnError overrides this in both directions:
// Throw in the frontend anyway
VTrans::translate($text, 'de', 'en', 'text', null, ['throwOnError' => true]);
// Fail silently in the backend and get the source text back
VTrans::translate($text, 'de', 'en', 'text', null, ['throwOnError' => false]);getLastResultMeta() tells you whether the last call succeeded:
$meta = VTrans::getLastResultMeta();
if (!empty($meta['failed'])) {
// $meta['errorType'] => quota | auth | timeout | other
// $meta['httpStatus'] => e.g. 456 (DeepL) or 429, when it can be determined
// $meta['error'] => the provider's message
}The errorType is also stored in the record's data column and is therefore visible on the Data page. quota means the contingent or rate limit was reached — a later attempt may succeed. auth means the connection's credentials were rejected.
If a backend user is signed in in the same browser, the error message is additionally rendered in the frontend — as a small block after the content for format = 'html', as an appended […] hint for format = 'text'. Regular visitors see none of it, only the untranslated text.
Provider messages are redacted before they are stored, logged or displayed: query parameters such as key= or api_key=, Bearer and DeepL-Auth-Key values, and the AWS credential headers are replaced with ***. Guzzle embeds the full request URI in its exception messages, and Google Translate Basic v2 and MyMemory pass the API key as a query parameter — without redaction the key would end up in the data column, which is readable by every user with the vtrans[] permission.
This example simply translates the German original content and outputs it in another language when no content is available for that language — meaning the article is empty.
<?php
use FriendsOfRedaxo\VTrans\VTrans;
// Current language of the REDAXO article context
$curLang = rex_clang::getCurrent()->getCode();
// Content of the current article
$articleContent = $this->getArticle();
// If we are in a non-default language and there is no content yet,
// retrieve the German original content and let vTrans translate it.
if (rex_clang::getCurrentId() !== 1 && $articleContent === '') {
// German original content from the base language (ID 1)
$articleContentOrg = (new rex_article_content(rex_article::getCurrentId(), 1))->getArticle();
$articleContent = VTrans::translate(
$articleContentOrg, // Content to translate
'de', // Source language
$curLang, // Target language
'html', // Format
'artCont-' . rex_article::getCurrentId() // Key for better caching
);
}
?>
<!DOCTYPE html>
<html lang="<?php echo htmlspecialchars($curLang, ENT_QUOTES, 'UTF-8'); ?>">
<head>
<meta charset="utf-8">
<title>vTrans Demo</title>
</head>
<body>
<h1>vTrans Demo</h1>
<!-- Simple language switcher for the demo -->
<nav>
<?php foreach (rex_clang::getAll(true) as $lang): ?>
<?php if ($lang->getValue('id') === rex_clang::getCurrentId()): ?>
| <strong><?php echo htmlspecialchars($lang->getValue('name')); ?></strong>
<?php else: ?>
| <a href="<?php echo rex_getUrl($this->getValue('article_id'), $lang->getValue('id')); ?>">
<?php echo htmlspecialchars($lang->getValue('name')); ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</nav>
<?php echo $articleContent; ?>
</body>
</html>In this example, only the actual content is translated. Other parts such as navigation, footer, meta data, etc. can be translated in the same way. For navigation, manual translation is often recommended, because this often also affects the URL.
With cache => false, a request can be executed directly via the API without first checking the database and without saving the result afterwards.
$translated = VTrans::translate(
$text,
'de',
'en',
'text',
null,
[
'connection' => 'openai_default',
'cache' => false,
]
);
$meta = VTrans::getLastResultMeta();
$data = VTrans::getLastResultData();In no-cache mode:
- no database lookup is performed before the provider call
- no result is stored in
rex_vtrans - raw provider data is still available directly via
VTrans::getLastResultData()
VTrans::getLastResultData() returns the raw data from the last request, such as usage data, rate limits, debug information, or provider-specific metadata.
When using the html format, a provider-independent HTML filter automatically protects certain content before the API call and restores it after translation.
<span translate="no">Thomas König</span>
<span class="notranslate">REDAXO CMS</span><div data-vtrans-exclude>
<script>var config = { lang: 'de' };</script>
<p>This block is not sent to the API.</p>
</div>{{ … }}, {% … %} and {# … #} are always protected — in text as well as inside attribute values. This matters when a template source is translated before it is rendered: otherwise a provider may "translate" {% endfor %} and break the template. Attribute values made of template syntax only (alt="{{ product.name }}") are not sent. HTML without these delimiters is not affected.
Most providers translate only text in HTML mode and leave attributes alone. vTrans therefore takes the values of translatable attributes out of their tags, sends them along in the same request as text of their own, and writes the translations back afterwards. There are no additional API calls.
Translated by default: alt, title, placeholder, aria-label, aria-description,
aria-roledescription, aria-placeholder, and value on buttons (<input type="button">,
submit, reset — on every other element value is data and stays untouched).
Values without letters (aria-label="2"), URLs, paths, file names (IMG_1234.jpg) and values
containing markup are left alone, as is everything inside translate="no", .notranslate
and data-vtrans-exclude — including a marker on the element itself, e.g.
<img class="notranslate" alt="Brand name">.
This is always on and needs no configuration.
Attribute values are translated without their surrounding sentence. Short values like a brand name can come back translated literally; mark the element with
translate="no"in that case.
Everything that is stored in the translation column is sanitised on the way in, because
neither source is admin-trusted: the provider's answer, and the edit form on the Data page,
which is open to every user holding the vtrans[] permission.
Removed are <script>, <style>, <iframe>, <object>, <form>, <base>, <link>,
<meta>, all on* event attributes and javascript and data URLs. Ordinary article markup
is kept: links, images, srcset, class, id, inline style, title, lang, dir,
role, all data-* and aria-* attributes, tables and lists.
data-* and aria-* stay because Bootstrap and similar components depend on them
(data-bs-toggle, data-bs-target, aria-expanded, …) and the browser never executes their
values. JavaScript on your site can, though: libraries such as Knockout (data-bind) or htmx
(data-hx-*) turn data attributes into code or requests. If you use such a library, keep that
markup out of translated regions with .notranslate or data-vtrans-exclude.
Nothing you marked yourself is affected: your own <script> and <style> blocks and every
region carrying data-vtrans-exclude, translate="no" or .notranslate are removed by the
HTML filter before the API call and restored afterwards — that is, after sanitisation — so
they never pass through it. A <button class="notranslate" onclick="…"> therefore keeps its
handler. Sanitisation happens on write, never on read: a cached record is returned unchanged.
Records written before this version were stored unsanitised. If a translation was edited manually back then, it is worth reviewing it.
- HTML sanitisation on the connection page — switching it off stores the provider's answer and every manual
edit on the
Datapage raw, and the frontend renders it as unfiltered HTML on every cache hit. Only defensible if you trust both the provider and everyone holdingvtrans[]. Existing connections keep sanitisation on; the setting has to be switched off deliberately.
Prefer marking the markup in question as excluded over switching sanitisation off — an excluded region never reaches the sanitiser in the first place, and nothing else on the page is weakened.
<script>…</script><style>…</style><code>…</code><svg>…</svg>
Records whose source contains translatable attributes or data-* / aria-* attributes get a
new cache hash with this version. They are re-translated once, on their next request — keyed
records are updated in place — and then carry translated attributes and keep their data and
ARIA attributes. Records without such attributes keep their hash and are not sent again.
- Project: vTrans on GitHub
- Community: REDAXO
- Slack: FOR Slack Channel
- Friends Of REDAXO
- Matthias Weiss / VIEWSION.net (Lead)
- Tobias Krais (ai_platform provider)
MIT, see LICENSE.