Creating Content in Multiple Locales with Fluent
SilverStripe is an extensive framework and CMS. As such, it is only reasonable that a powerful locale-system should be available. Today we will be working with Fluent and see how we programmatically can use it when we create new DataObjects.
Background
In general terms: a locale is a way to specify a place and language so only content appropriate for it is shown. We can use a locale string such as sv_SE as an URL-parameter and have the website show us content in Swedish or applicable to the Swedish context.
There are many ways to implement a system for dealing with locales on a website. Some systems employ a key-value solution for minor locale-specific details - user interfaces are examples on that. A key could be warning-button with a value of Warning in English, and a value of Varning in Swedish. But there are other solutions for content driven sites. One solution is Fluent.
Fluent is a SilverStripe module that has a smaller footprint compared to one of its predecessors Translatable, while maintaining a sane interface for content over multiple locales. It’s smaller footprint is in regards to the database load. While this may be of smaller concern, it may please the database maintainers to see that the Fluent system tries to minimize repeating of identical data over multiple rows or tables. Fluent creates localized tables for any table in an inheritance tree that has fields that are translatable (or assumed to be so). For example: a Page is extending SiteTree, and if we have text-fields in the Page we will get a localized table for both SiteTree and Page. However, if Page did not contain a text-field or any other translatable fields we would only get a localized table for SiteTree. We also get tools to specify what fields to include or exclude - even relations. The configuration specifics are documented on GitHub.
Fluent works from having a few locales added to your database. When installed into your project it provides a CMS interface where users may add how many locales they wish. For today’s purpose it’s sufficient having at least two locales to work with. One of these locales will be the default locale. Of course, we have control over that as well. Let’s assume we have created these locales:
Name |
Locale |
IsGlobalDefault |
English |
en_GB |
1 |
Swedish |
sv_SE |
0 |
Table 1 - Fluent_Locale
We have two locales, English and Swedish. English is set as the default locale. That means that English content is displayed on our website if we don’t specify any locale, as we may do with adding either en_GB or sv_Se as a URL parameter.
Fluent with DataObjects
Before we start programmatically create DataObjects that are localized, we need to take a look at how Fluent structures localized DataObjects. Since SilverStripe is already doing a couple of things that can appear magical to us, it’s good to first get acquainted with SilverStripe’s database structure for DataObjects before we focus on the specifics of Fluent.
Let’s return to Page. Page is also a SiteTree. The power of inheritance means that a class is whatever types it inherits from. If we were to have a CategoryPage that extends Page, then CategoryPage would be whatever types it consecutively inherits from: CategoryPage, Page and SiteTree (and DataObject). SilverStripe will create tables that match against each inherited class, except for DataObject. DataObject is unique in that it provides the blueprint for how SilverStripe will interact with the database, while it self is not represented as a database table.
Each new record for Page will also be a new record for SiteTree. Let’s say we were to create an ”About us” Page. Page has every field SiteTree has, and adding a field for an hero-image. We will add the title ”About us” and not bother with any of the other fields. The resulting database entries would be this:
ID |
ImageID |
1 |
0 |
Table 2.1 - Page
ID |
ClassName |
Title |
… |
1 |
Page |
About us |
… |
Table 2.2 - SiteTree
The Page table (Table 2.1) just holds whatever is unique for the class (the image), and since we didn’t specify any image for ”About us” it’s just a row with the ID 1, and no reference to any Image-entries. Instead, the title is stored in the table for SiteTree (Table 2.2). In the SiteTree table we have the title and also the ClassName that the table-row references. We can now also see that the ID in SiteTree and Page tables are the same; it’s the same object they are referencing.
Let’s move on to the Fluent specifics. As we mentioned earlier, Fluent is trying to localize fields it assumes should be localized. In this example we haven’t specified that the Image should be localized and as there are no other fields there, Fluent will disregard the whole Page-table. As for SiteTree, there are a couple of fields that will be localized. Title is one of these.
When Fluent localizes a class, it will look for each table in the inheritance tree and create a _Localised version for it. Page will not get a _Localised version as it doesn’t have any fields to be localized, but SiteTree does. This means that Fluent will create a SiteTree_Localised table containing the fields that will be localized and keys to the row in SiteTree that it references. So if we create an English version of ”About us” and a Swedish version (”Om oss”), we will have the following entries in the SiteTree_Localised table:
ID |
RecordID |
Locale |
Title |
… |
1 |
1 |
en_GB |
About us |
|
2 |
1 |
sv_SE |
Om oss |
Table 2.3 - SiteTree_Localised
Both entries in the _Localised table references the same RecordID. This is the one entry we have in SiteTree. While we may have created this entry in English, both entries references it. Any field in SiteTree that is localized will be supplanted by the field from the _Localised table. So if we were to visit our website example.com/sv_SE/om-oss we will get a Page-instance with the title ”Om oss”. This is the power of Fluent (and similar localization systems).
Creating localized content through the CMS is straightforward. By the press of a button we can switch between locales and create content appropriate for that context. But when we want to do this programmatically it can be a bit tricky. Reasons for doing this programmatically may be that we are upgrading to a new major release of SilverStripe (moving from Translatable to Fluent), or converting a DataObject to a new type (maybe we want elementals where we have used pages previously), or we are consuming a REST API and creating content based on that. No matter where we are coming from, we just may need to program how we create localized content.
Programmatically localized content
As we have seen, there are multiple tables to keep track of when working with localized content in SilverStripe. Fluent makes it a bit easier for us to work with this complexity by doing a lot of the work behind the scene. As long as we let Fluent know in what locale it should be working in, it can handle the rest for us.
It all works through FluentState. FluentState is a class that provides a singleton for us to work through. To ease us into working with it, let’s see how we can fetch localized content:
Example 3.1
$title = FluentState::singleton()
->setLocale("sv_SE")
->withState(function (FluentState $state) use ($recordID, $elementAreaID){
$page = Page::get()->first();
return $page->Title;
});
// $title will have the value ”Om oss”
Setting the locale for FluentState informs it in which context it will be working. We use this information as we create new content. Let’s assume that we have a REST API that serves us a JSON-body with localized content:
Example 3.2
{
"items": [
{
"id": 1,
"localizedContent": {
"en_GB": {
”title": "REST with Roy"
},
"sv_SE": {
"title": "REST med Roy"
}
}
}
]
}
For this example, we have added a field to our Page-class to keep track of what API-result item it has been mapped from (we call it ”ApiID”, of type ”Int”):
private static $db = [
"ApiID" => "Int"
];
Next up we load the JSON from a file and see how we can work with it:
Example 3.3
$locales = Locale::get(); // Returns a DataList with Locale data-objects we can iterate over.
$json = file_get_contents("app/assets/locale-api.json");
$result = json_decode($json, true);
foreach ($result["items"] as $item){
$apiID = $item["id"];
$page = Page::get()->filter([
"ApiID" => $apiID
])->first();
if ($page === null) {
$page = Page::create();
$page->ApiID = $apiID;
$page->write();
}
/** @var Locale $locale */
foreach ($locales as $locale) {
$localeString = $locale->Locale;
FluentState::singleton()
->setLocale($localeString)
->withState(function (FluentState $state) use ($localeString, $item, $page) {
// Check if our current locale exist in the body
if (key_exists($localeString, $item["localizedContent"])) {
$title = $item["localizedContent"][$localeString]["title"];
$page->Title = $title;
$page->write();
}
});
}
}
First we get all the locales that we have created for our website (English and Swedish). We will use this within the foreach-loop to inform Fluent what locale it will work with. Then we load the JSON-file (this might just as well be a REST API-result) and iterate over each item from the ”API”.
We look and see if we have any Page already mapped against the ApiID. If no one exists we create it. This page will be used as we move into locales and move into FluentState for each locale. Within FluentState we fetch the item corresponding to the current locale and write fields with the localized values. In this example we will write the English and Swedish titles to the page. This might look like we are overwriting the same page’s title every time - but we are actually creating new entries to the _Localised table where each are referencing the same RecordID.
This has been an example on how to programmatically write localized content with Fluent. We have briefly looked at the database structure of a common SilverStripe project and how Fluent is a module adding new features upon it. With this knowledge we have seen how we can fetch and add content to it. Some features may seem magical at first but it all comes down to the context that the FluentState singleton is working within.
In closing: ”Don’t Panic.” - Douglas Adams, The Hitchhiker’s Guide to the Galaxy
Photo by Karolina Grabowska from Pexels