In this post, I'll explain how to use TYPO3 as a source for your Symfony translations. Doing so will enable you to have translations for your backend without maintaining any local language files.
This will require a little bit more of a setup to begin with, but handling translations in a CMS, in the long run will save you time, money and headaches! After all, translations are just that: Content!
The best part about using TYPO3 for translations is that you can reuse and share these translations over multiple projects, different stages or even different Apps/Frontends/Backends without having to duplicate anything.
In my other blog post, I have already explained how TYPO3 can be used as a source for react-i18-next languages files to translate an app or website. We will use the same setup to also translate our backend.
Why would we do this?
A good example why we would need the same translations in the frontend and the backend are the CV-templates I have on https://bewerbungshelferlein.de/ or the billing templates on https://freelancingtoolkit.com/. These templates are pure HTML/CSS and do get rendered by Symfony via the Twig framework in the backend. However, they also need to have the same wording in the frontend.
Important: When I say translations, I do not talk about UGC or anything that is stored in the Database at all. To translate Doctrine Entities, you should use a different approach.
Even for my own projects where I'm the developer, this is a huge time safer. For example, I never have to do a deployment or need access to the code just to fix a spelling mistake or changing a wording. Now imagine how happy your stakeholders/customers will be when they will never need a developer to change the language or fix spelling issues. That saves a lot of time and money!
Basic setup
Let's start with a quick recap on how the setup works and how it is built. I have TYPO3 set up with the typo3 headless extension. This provides me with a URL like so:
{{baseURL}}/translation/[language]
This URL provides me with a JSON language file. I can manage and update this file easily via the TYPO3 backend. To get more of an Idea of how it looks, you can check out the translations for the Bewerbungshelferlein here for the German version and here for the English translation.
However, to add new translations for new features, you still need a developer to add the translation placeholders in the code.
And to add a little bit more details, here is a small screenshot of how the backend in Typo3 looks like. If you want a bit more information about how the setup is build, check out the blog post about react18next and TYPO3.
Preparing Symfony to use the TYPO3 translation files
Time for some hands on action - let's get our hands dirty. There are three major steps that need to be taken to make this whole thing work in Symfony:
- Install and configure the needed packages
- Create the custom translation provider
- Using the setup in the backend and in a twig template
1. Installation and configuration
Before we start, we need to install and configure the Symfony translation bundle. This is the base for everything we do here. The config I'm using is pretty straight forward and there is a very good documentation on the Symfony website, so I won't explain it here.
Install:
composer require symfony/translation
Configure:
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
Translation files:
I'm not 100% sure if this is a result of how I configured it, how I programmed it or if this is a bug, but there is still one important thing left to do to make this work. It also sounds very counterintuitive since the translations are loaded via the web, but we still have to create the translations files, in our '%kernel.project_dir%/translations' folder (if you don't have that folder create it). We need one translation file per language. They can even be empty, but they need to exist.
touch ./translations/messages.de.web
2. Creating the custom loader service
The next step is to create a service that loads and stores the translations from our web resource. Since I have created a reusable bundle that I use in all my projects, my service is called:
serie3HelperBundle.TranslationWebLoader
In my services.yml file, I added a new entry:
serie3HelperBundle.TranslationWebLoader:
class: Serie3\HelperBundle\Services\TranslationWebLoader
arguments: ["@parameter_bag"]
tags:
- { name: 'translation.loader', alias: 'web' }
Next, we create the actual translation loader service class, which implements the LoaderInterface
class TranslationWebLoader implements LoaderInterface
{
public function __construct(private ParameterBagInterface $params){}
public function load(mixed $resource='', string $locale='', string $domain = 'messages'): MessageCatalogue
{
$baseUrl = $this->params->get('serie3_user.translation_url');
if($locale !== 'en'){
$baseUrl = $baseUrl.$locale;
}
$catalogue = new MessageCatalogue($locale);
if ($data = @file_get_contents($baseUrl)) {
$translations = json_decode($data, true);
$catalogue = new MessageCatalogue($locale);
// Start the recursive addition of translations
$this->addTranslations($catalogue, $translations, [], $domain);
} else {
// Handle the error if the content could not be fetched
$error = error_get_last();
throw new \RuntimeException('Could not fetch translations: ' . $error['message']);
}
return $catalogue;
}
/**
* Recursively adds translations to the catalogue.
*
* @param MessageCatalogue $catalogue
* @param array $translations
* @param array $keyPath Holds the nested keys leading to the current translation
* @param string $domain
*/
private function addTranslations(MessageCatalogue $catalogue, array $translations, array $keyPath, string $domain): void
{
foreach ($translations as $key => $value) {
// Add the current key to the key path
$currentKeyPath = array_merge($keyPath, [$key]);
if (is_array($value)) {
// If the value is an array, recurse into it
$this->addTranslations($catalogue, $value, $currentKeyPath, $domain);
} else {
// Join the keys with a dot to form the final translation key
$finalKey = implode('.', $currentKeyPath);
// Add the translation to the catalogue
$catalogue->add([$finalKey => $value], $domain);
}
}
}
}
Let's break down the most important stuff here:
$baseUrl = $this->params->get('serie3_user.translation_url');
Because I'm reusing this bundle, I have created an environment variable to tell the loader where to get the translations from. If you're not planning on reusing your translation service in other projects, you don't need that and can hard-code the translation URL.
However: Hard-coding stuff always sucks! Don't be lazy and create an environment variable!
The next important part is here:
if ($data = @file_get_contents($baseUrl)) {
$translations = json_decode($data, true);
$catalogue = new MessageCatalogue($locale);
// Start the recursive addition of translations
$this->addTranslations($catalogue, $translations, [], $domain);
} else {
// Handle the error if the content could not be fetched
$error = error_get_last();
throw new \RuntimeException('Could not fetch translations: ' . $error['message']);
}
What we do here is to load the data from the base URL we just created and add the translations to the new translation catalog.
3. How to use it
In my apps, the users can decide what language they want to use, the default is set to English. If the user now changes that, the preference is stored in the database and every time the user comes back he/she gets the correct translations.
Important: You might have noticed that I'm not using a language switch in the URL here, and get the preference from the database instead. This is a problem for search engines, but all the projects I do focus heavily on content that will not be indexed by a search engine anyway. If you have a lot of things that will be public make sure to include a local parameter in your URL like /en/ for English or /de/ for German
Saving the language settings in the database enables me to run cronjobs and always get the correct translations even when the user is not online.
A good example is my PDF Creation Service. With this, I can create a bill for the user in the preferred language even when the user is not online.
public function __construct(private Environment $twig, private TranslatorInterface $translator, private Rights $rights){}
I do pass the translator interface to the PDF-Service and set the correct language for the translations
$this->translator->setLocale($locale);
In the twig template, I then can use the twig trans filter to translate the text like so:
{{ 'Templates.RemoteOnly'|trans }}
How the flow works
Caching problems
This approach works fine, however there is one major thing to watch out for: Cache! Whenever a translation got updated, you should also clear your Symfony cache to make sure the stored translations are updated as well.