PHP and Symfony
This tutorial will show you how to manage translation files with the Lokalise API and how to implement an OAuth 2 flow.
In this tutorial you'll learn how to...
- Work with Lokalise API tokens
- Upload translation files with the Lokalise API
- Download translation files with the Lokalise API
- Implement an OAuth 2 flow
- List customer projects
- Act on the customer's behalf and upload/download translation files
You can find the source code on GitHub.
Prerequisites
This guide assumes that you have a Lokalise project (if not, learn how to create your Lokalise project here). In this tutorial, we are going to upload English translations. Therefore, make sure that your project has the English language added with the “en” ISO code.
If you want to follow this guide locally on your computer, you need to have the following software installed:
- PHP 8.0.2 or above
- Symfony CLI tool
- Code editor
What we are going to build
In the first part of this tutorial, we are going to create a simple Symfony/Lokalise integration to showcase how easy it is to transfer the translations between your app and Lokalise. It's the "fast track to exchanging translations".
The second part will cover building a simple Symfony app that uses the Lokalise PHP SDK to accomplish this. It will involve writing a bit of PHP code, but will give us much greater control over the translation exchange.
In the third part, we will implement an OAuth 2 flow and act on the user's behalf, instead of using a generic API token. This will improve the audit trail left behind when exchanging translations, and will also take into account the user's specific permissions.
Reading all parts one by one is not required
You can simply jump to the section that you would like to study.
Preparing a new Symfony app
Create a new Symfony app by running the following command:
symfony new symfony-lokalise-app --webapp
This will create the skeleton app in the symfony-lokalise-app
folder for you.
Now go into that folder and set the Symfony app to use the translations:
cd symfony-lokalise-app
composer require symfony/translation
Getting the API token and project ID
You should obtain an existing API token or generate a new one. Learn how to get a Lokalise API token.
Also, while you are on Lokalise, get the project ID and write it down. Learn how to get the Lokalise project ID.
Using the Symfony/Lokalise integration
In this section, we will leverage the Lokalise translation provider, so let's go ahead and add that:
composer require symfony/lokalise-translation-provider
Adding the initial translations
The Lokalise translation provider package is a little opinionated about the file format it wants to use. We can either switch to Lokalise and create our translations there, or you can copy-paste the following snippet of code into your translations/messages.en.xlf
file – this adds four translation keys already translated into English for you:
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="lDKh8IR" resname="download_btn">
<source>download_btn</source>
<target>Download!</target>
</trans-unit>
<trans-unit id="GBX8E_C" resname="download_title">
<source>download_title</source>
<target>Download translation file</target>
</trans-unit>
<trans-unit id="W2gyj8w" resname="upload_btn">
<source>upload_btn</source>
<target>Upload!</target>
</trans-unit>
<trans-unit id="ZwPonJx" resname="upload_title">
<source>upload_title</source>
<target>Upload translation file</target>
</trans-unit>
</body>
</file>
</xliff>
Set up the app to use Lokalise as a translation provider
Make sure Lokalise is enabled as a translation provider. Your config/packages/translation.yaml
config file should look something like this:
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
providers:
lokalise:
dsn: '%env(LOKALISE_DSN)%'
locales: ['en', 'lv']
Two things to note here:
- We've also added a locale for Latvian. This is just for demonstration purposes – feel free to skip adding new languages, if you're so inclined.
- We've set the translator DSN to an environment variable of
LOKALISE_DSN
. Make sure to add that variable to the appropriate.env
file in the format oflokalise://<PROJECT_ID>:<API_KEY>@default
.
Never publicly expose your API key!
Specifically, don’t forget to add the
.env
file to.gitignore
.
Uploading and downloading the translations
Now we're set to start uploading and downloading the translations.
Upload the local translations to Lokalise, like so:
symfony console translation:push lokalise --domains messages --force
This tells Symfony to push the translations from the messages
domains for the lokalise
provider and overwrite any existing translations that you also have locally.
If you don't want to overwrite existing translations, simply omit the --force
option. Please note, though, that this will attempt to download the existing translations for comparison from the Lokalise server beforehand, which can result in an error if there are no translations for the web platform yet.
After uploading the translations, head over to Lokalise to make sure they all made it there in one piece. Edit some translations and then download them back to your project, using the following command:
symfony console translation:pull lokalise --domains messages --force
You should see your translations back in one piece with the changes included. If you added some more languages, you should see those files appearing in the translation folder as well.
Using the Lokalise PHP SDK
This is nice and all, but the XLF format might be a little bit heavy for simple apps, as there's a lot of additional metadata in the translation files. This makes manually adding new translations a bit of an issue, as you're developing the app locally. It would be quite useful to use a different format for faster manual addition of new keys as we go. As mentioned above, the Symfony/Lokalise integration is somewhat opinionated, so we'll switch to the Lokalise PHP SDK.
Cleaning up
If you completed the first part using the integration, let's do some cleanup first:
- Remove the integration with the following command:
composer remove symfony/lokalise-translation-provider
- Remove the translation files in
translations/
- Remove all the added translation keys in the project on Lokalise
- Clean up the
config/packages/translation.yaml
file to look like this:
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
Setting up the controllers
We'll set up two controllers: one for the homepage and the other for managing the translations. Let's generate these now by running:
symfony console make:controller HomeController
symfony console make:controller TranslationsController
Open up the src/Controller/HomeController.php
file and make sure the route is set to capture requests to the site root:
#[Route('/', name: 'app_home')]
public function index(): Response
{
return $this->render('home/index.html.twig');
}
Next, open the src/Controller/TranslationsController.php
file and set up the upload
and download
actions:
#[Route('/translations/upload', name: 'translations_upload', methods: ['POST'])]
public function upload(): Response
{
return $this->redirect($this->generateUrl('app_home'));
}
#[Route('/translations/download', name: 'translations_download', methods: ['POST'])]
public function download(): Response
{
return $this->redirect($this->generateUrl('app_home'));
}
Note that we're setting both of these actions only to respond to POST
requests. Initially, we're making them redirect back home.
Setting up the homepage template
Open up the templates/home/index.html.twig
file and make it something simple that allows you to upload and download the translations, while also showcasing the translations themselves:
<h1>Manage the translations here</h1>
<h2>{{'download_title'|trans}}</h2>
<form action="{{ path('translations_download')}}" method="post"><input type="submit" value="{{'download_btn'|trans }}" /></form>
<h2>{{'upload_title'|trans}}</h2>
<form action="{{ path('translations_upload')}}" method="post"><input type="submit" value="{{'upload_btn'|trans }}" /></form>
Setting up the Lokalise PHP SDK
Add the SDK to your app by running the following command:
composer require lokalise/php-lokalise-api
Now is as good a time as any to store your credentials in the .env
file. From now on, we'll assume that they're stored as follows:
LOKALISE_PROJECT=123.abc
LOKALISE_TOKEN=123def456
Make sure to add them to your service configuration in config/services.yaml
:
parameters:
lokalise.api_token: '%env(LOKALISE_TOKEN)%'
lokalise.project_id: '%env(LOKALISE_PROJECT)%'
Adding initial translations
Open the translations/messages.en.yaml
file and add the initial translations, as below:
upload_title: Upload translation file
upload_btn: Upload!
download_title: Download translation file
download_btn: Download!
Start up the Symfony app
Simply execute symfony server:start
in the root of the project to start the built-in server. Afterward, the app should show up on [<http://localhost:8000
>](http://localhost:8000`).
Uploading translation files to your Lokalise project
Let's update the upload
action so it actually uploads any translation files it finds:
public function upload(): Response
{
// Get all the message translation files
$finder = new \Symfony\Component\Finder\Finder()
$files = $finder->files()
->in($this->getParameter('kernel.project_dir') . '/translations')
->name('messages.*.yaml');
// If any files were found
if ($files->hasResults()) {
$client = new \Lokalise\LokaliseApiClient($this->getParameter('lokalise.api_token'));
foreach ($files as $file) {
$contents = $file->getContents();
$fileName = $file->getFilename();
$languageIso = explode('.', $fileName)[1];
// Upload the file
$client->files->upload($this->getParameter('lokalise.project_id'), [
'data' => base64_encode($contents),
'filename' => $fileName,
'lang_iso' => $languageIso
]);
}
}
// Redirect back home.
return $this->redirect($this->generateUrl('app_home'));
}
Now, visit the homepage (http://localhost:8000) and hit "Upload!". It'll take a few seconds to process everything, and then it will return to the homepage.
Visit your Lokalise project online to confirm that the translations have been uploaded.
Downloading translation files from your Lokalise project
Downloading takes a little extra effort, because the download will be a .zip file. Most PHP installations have the .zip extension enabled. If you do not, this is a good time to install it.
Once that is taken care of, everything else is a breeze!
public function download(): Response
{
$client = new \Lokalise\LokaliseApiClient($this->getParameter('lokalise.api_token'));
$response = $client->files->download($this->getParameter('lokalise.project_id'), [
'format' => 'yaml',
'yaml_include_root' => false,
'original_filenames' => true,
'directory_prefix' => '',
'indentation' => '4sp',
]);
$url = $response->getContent()['bundle_url'] ?? null;
$tempFile = tempnam(sys_get_temp_dir(), 'lokalise');
copy($url, $tempFile);
file_put_contents($tempFile, file_get_contents($url));
$zip = new \ZipArchive();
if ($zip->open($tempFile)) {
$zip->extractTo($this->getParameter('kernel.project_dir') . '/translations');
$zip->close();
}
unlink($tempFile);
return $this->redirect($this->generateUrl('app_home'));
}
Now, hitting that "Download!" button will download the translation files from Lokalise.
Note: if you are updating existing translations, you might need to clear the cache by running symfony console cache:clear
.
Your existing translations will be overwritten!
Please remember that the current implementation overwrites any data inside the translation files. If you don't want this to happen, save your new translations to a file with another name.
Working with OAuth 2 flow
Yet again, the Lokalise PHP SDK does not currently support OAuth 2, so we'll resort to some manual requests using Guzzle. Let's get ready for that.
Cleaning up
To clean up after the PHP SDK part of this tutorial you should:
- Remove the SDK with the following command:
composer remove lokalise/php-lokalise-api
- Remove the
src/Controller/TranslationsController.php
file as we won't need it - Remove the
templates/translations
folder as we won't need it
Keep the translations as we're able to reuse them. Or, if you skipped straight to here without doing the PHP SDK part, perform this step only from the PHP SDK section: Adding initial translations.
Registering an OAuth 2 app
To get started, please reach out to our tech support and ask them to register an OAuth 2 app for you (if you don't have one registered already). You will be presented with the Lokalise client ID and client secret that we will be using in the next steps.
You can store these keys in the .env file:
LOKALISE_OAUTH2_CLIENT_ID=your_id
LOKALISE_OAUTH2_CLIENT_SECRET=your_secret
Make sure to add them to your service configuration in config/services.yaml
. While we're editing that file, let's add the OAuth 2 endpoint and requested scopes as well.
parameters:
lokalise.oauth.client_id: '%env(LOKALISE_OAUTH2_CLIENT_ID)%'
lokalise.oauth.client_secret: '%env(LOKALISE_OAUTH2_CLIENT_SECRET)%'
lokalise.oauth.endpoint: 'https://app.lokalise.com/oauth2/'
lokalise.oauth.scopes:
- 'read_projects'
- 'read_files'
- 'write_files'
Creating the initial OAuth controller
Create the controller with the following command:
symfony console make:controller OAuthController
Let's set up the OAuth home action first – open the src/Controller/OAuthController.php
file and set up the index
action:
#[Route('/oauth', name: 'oauth_home')]
public function index(SessionInterface $session): Response
{
return $this->render('o_auth/index.html.twig',[
'lokaliseToken' => $session->get('lokalise.oauth.token', null),
'lokaliseRefreshToken' => $session->get('lokalise.oauth.refresh_token', null),
'lokaliseProjectId' => $session->get('l okalise.oauth.project_id', null),
]);
}
This will look at the current session and render a simple template using the OAuth session variables that we'll store a bit later.
Set up the template to look like this:
{% if lokaliseToken is not null %}
<h2>OAuth 2 session status</h2>
<ul>
<li>Your OAuth 2 token: {{ lokaliseToken }}</li>
<li>Your OAuth 2 refresh token: {{ lokaliseRefreshToken }}</li>
<li>Your OAuth 2 project id: {{ lokaliseProjectId }}</li>
<li><a href="{{ path('oauth_logout')}}">Log out</a></li>
</ul>
{% else %}
<a href="{{ path('oauth_authenticate')}}">Log in via Lokalise</a>
{% endif %}
If you try to go to <http://localhost:8000/oauth
> now, you should see an error, so let's set up the authentication flow and ensure that the oauth_authenticate
route exists.
Implementing the OAuth 2 flow
Once again open the src/Controller/OAuthController.php
file so we can implement the authentication flow:
#[Route('/oauth/authenticate', name: 'oauth_authenticate')]
public function authenticate(\Symfony\Component\HttpFoundation\Session\SessionInterface $session)
{
// Generate a new state and store it for lookup later
$state = uniqid('lok.oauth', true);
$session->set('lokalise.oauth.state', $state);
$redirectUri = $this->generateUrl('oauth_callback');
// Build the redirect URL and redirect the user to that
$params = [
'client_id' => $this->getParameter('lokalise.oauth.client_id'),
'scope' => implode(' ', $this->getParameter('lokalise.oauth.scopes')),
'redirect_uri' => $this->generateUrl('oauth_callback', [], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL),
'state' => $state,
];
$authUrl = $this->getParameter('lokalise.oauth.endpoint') . 'auth?' . http_build_query($params);
return $this->redirect($authUrl);
}
There's a bit to unpack here, but there are two main steps here:
- We generate a unique
state
code, which we can use to prevent CSRF attacks, and store that in the user session. - Then, we generate the URL which includes the parameters to set up authentication as well as the redirect URL where the user will be sent.
Now, let's set up redirect handling by adding the following action to our controllers:
#[Route('/oauth/callback', name: 'oauth_callback')]
public function callback(\Symfony\Component\HttpFoundation\Session\SessionInterface $session, Symfony\Component\HttpFoundation\Request $request, Symfony\Contracts\HttpClient\HttpClientInterface $client): Response
{
$code = $request->get('code', null);
// Check the integrity of the request
if (empty($code) || $session->get('lokalise.oauth.state', 'state1') !== $request->get('state', 'state2') ) {
return $this->redirect($this->generateUrl('oauth_home'));
}
// Get the tokens based on the code returned
$params = [
'grant_type' => 'authorization_code',
'code' => $code,
'client_id' => $this->getParameter('lokalise.oauth.client_id'),
'client_secret' => $this->getParameter('lokalise.oauth.client_secret'),
];
$response = $client->request('POST', $this->getParameter('lokalise.oauth.endpoint') . 'token', [
'json' => $params
]);
// Store the token data in session
$responseData = $response->toArray();
$session->set('lokalise.oauth.token', $responseData['access_token'] ?? null);
$session->set('lokalise.oauth.refresh_token', $responseData['refresh_token'] ?? null);
$session->set('lokalise.oauth.project_id', null);
return $this->redirect($this->generateUrl('oauth_home'));
}
This action initially checks whether a one-time authorization code was present in the response and compares the stored OAuth state key with the returned one. This helps us make sure that we're actually talking to the same entity that we sent our request to. When those checks have passed, we create the request parameter for the subsequent request, which will actually return the tokens for us to use.
Finally, let's add another action to the same controller which will allow the user to log out. All that requires is simply nullifying some session variables:
#[Route('/oauth/logout', name: 'oauth_logout')]
public function logout(\Symfony\Component\HttpFoundation\Session\SessionInterface $session): Response
{
$session->set('lokalise.oauth.token', null);
$session->set('lokalise.oauth.refresh_token', null);
$session->set('lokalise.oauth.project_id', null);
return $this->redirect($this->generateUrl('oauth_home'));
}
After this, you should be able to authenticate using OAuth 2 successfully – try it by visiting <http://localhost:8000/oauth
>.
Setting up for authenticated actions
There will be some shared functionality between all the authenticated actions. In a real-world application you would probably build a service for that or have some helper component or class. However, in this article, we're aiming for easy to write and easy to follow. Sometimes you have to sacrifice the best practices to do this, so just keep in mind that this demonstrates a general application, not the best way of structuring your application.
Before we continue with user actions that only authenticated users can perform, we have to set up some control mechanisms. While in Symfony the common way would be to use events, for simplicity we'll go with a less flexible approach and implement a base controller for such actions. While we're at it, we'll make sure we have an HTTP client at our fingertips that's already primed for authenticated requests.
Create the src/Controller/OAuthAuthenticatedController.php
file manually and add some simple code to allow us to check for authentication:
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
abstract class OAuthAuthenticatedController extends AbstractController
{
protected SessionInterface $session;
protected HttpClientInterface $client;
public function __construct(RequestStack $requestStack, HttpClientInterface $client) {
$this->session = $requestStack->getSession();
$this->client = $client->withOptions([
'auth_bearer' => $this->session->get('lokalise.oauth.token', 'no-token')
]);
}
protected function checkToken(): bool
{
return !empty($this->session->get('lokalise.oauth.token', null));
}
protected function checkProjectId(): bool
{
return !empty($this->session->get('lokalise.oauth.project_id', null));
}
}
Choosing a project to work with
The next step is allowing the customer to choose a project that they'd like to interact with. So, let's add another controller:
symfony console make:controller ProjectsController
Inside that controller we'll make sure to fetch a list of projects. First, let's ensure that it extends the right controller, though:
class ProjectsController extends OAuthAuthenticatedController
Next, be sure to add two actions to the controller that will request the project list and let you select a project, too:
#[Route('/projects', name: 'list_projects')]
public function index(): Response
{
if (!$this->checkToken()) {
return $this->redirect($this->generateUrl('oauth_home'));
}
$projects = $this->client->request('GET', $this->getParameter('lokalise.api.endpoint') . 'projects', [
'query' => [
'limit' => 50
]
])?->toArray() ?? [];
$projects = array_map(fn($data) => ['id' => $data['project_id'], 'name' => $data['name']], $projects['projects']);
return $this->render('projects/index.html.twig', [
'projects' => $projects,
]);
}
#[Route('/select_project/{projectId}', name: 'oauth_select_project')]
public function select(\Symfony\Component\HttpFoundation\Session\SessionInterface $session, string $projectId): Response
{
if ($this->checkToken()) {
$session->set('lokalise.oauth.project_id', $projectId);
}
return $this->redirect($this->generateUrl('oauth_home'));
}
Also, make sure to add some simple markup to the templates/projects/index.twig.html
to make sure the user is able to select a project:
<h2>Select a project</h2>
<ul>
{% for project in projects %}
<li><a href="{{ path('oauth_select_project', {projectId: project.id}) }}">{{ project.name }}</a></li>
{% endfor %}
</ul>
Also, update the templates/o_auth/index.html.twig
template so it looks like this:
{% if lokaliseToken is not null %}
<h2>OAuth 2 session status</h2>
<ul>
<li>Your OAuth 2 token: {{ lokaliseToken }}</li>
<li>Your OAuth 2 refresh token: {{ lokaliseRefreshToken }}</li>
<li>Your OAuth 2 project id: {{ lokaliseProjectId }}</li>
<li><a href="{{ path('oauth_logout')}}">Log out</a></li>
</ul>
<hr>
<ul>
<li><a href="{{ path('list_projects') }}">Select a project</a></li>
</ul>
{% else %}
<a href="{{ path('oauth_authenticate')}}">Log in via Lokalise</a>
{% endif %}
Uploading and downloading translations
Lastly, let's set up translation file transfer on the user's behalf.
This will look very similar to using the Lokalise PHP SDK, but that's expected – we are, after all, doing almost exactly the same thing, only authenticating in a different manner.
Set up a new controller using the following command:
symfony console make:controller OAuthTranslationsController
Then, update it to appear like this:
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Finder\Finder;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Translation\Reader\TranslationReaderInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class OAuthTranslationsController extends OAuthAuthenticatedController
{
#[Route('/oauth/upload', name: 'oauth_upload')]
public function upload(SessionInterface $session): Response
{
if (!$this->checkToken() || !$this->checkProjectId()) {
return $this->redirect($this->generateUrl('oauth_home'));
}
// Get all the message translation files
$finder = new Finder();
$files = $finder->files()
->in($this->getParameter('kernel.project_dir') . '/translations')
->name('messages.*.yaml');
// If any files were found
if ($files->hasResults()) {
foreach ($files as $file) {
$projectId = $session->get('lokalise.oauth.project_id');
$contents = $file->getContents();
$fileName = $file->getFilename();
$languageIso = explode('.', $fileName)[1];
$this->client->request('POST', $this->getParameter('lokalise.api.endpoint') . 'projects/' . $projectId . '/files/upload', [
'json' => [
'data' => base64_encode($contents),
'filename' => $fileName,
'lang_iso' => $languageIso
]
]);
}
}
// Redirect back home.
return $this->redirect($this->generateUrl('oauth_home'));
}
#[Route('/oauth/download', name: 'oauth_download')]
public function download(SessionInterface $session): Response
{
if (!$this->checkToken() || !$this->checkProjectId()) {
return $this->redirect($this->generateUrl('oauth_home'));
}
$projectId = $session->get('lokalise.oauth.project_id');
$response = $this->client->request('POST', $this->getParameter('lokalise.api.endpoint') . 'projects/' . $projectId . '/files/download', [
'json' => [
'format' => 'yaml',
'yaml_include_root' => false,
'original_filenames' => true,
'directory_prefix' => '',
'indentation' => '4sp',
]
]);
$url = $response->toArray()['bundle_url'] ?? null;
$tempFile = tempnam(sys_get_temp_dir(), 'lokalise');
copy($url, $tempFile);
file_put_contents($tempFile, file_get_contents($url));
$zip = new \ZipArchive();
if ($zip->open($tempFile)) {
$zip->extractTo($this->getParameter('kernel.project_dir') . '/translations');
$zip->close();
}
unlink($tempFile);
return $this->redirect($this->generateUrl('oauth_home'));
}
}
We are setting up the upload and download actions, which uses Symfony's HttpClient, instead of the PHP SDK. This is done because OAuth 2 requests require an authentication token to be sent as a header, which is not yet supported by the PHP SDK.
After this step, you'll also need to update the templates/o_auth/index.html.twig
template to:
{% if lokaliseToken is not null %}
<h2>OAuth 2 session status</h2>
<ul>
<li>Your OAuth 2 token: {{ lokaliseToken }}</li>
<li>Your OAuth 2 refresh token: {{ lokaliseRefreshToken }}</li>
<li>Your OAuth 2 project id: {{ lokaliseProjectId }}</li>
<li><a href="{{ path('oauth_logout')}}">Log out</a></li>
</ul>
<hr>
<ul>
<li><a href="{{ path('list_projects') }}">Select a project</a></li>
<li><a href="{{ path('oauth_upload')}}">{{'upload_btn'|trans }}</a></li>
<li><a href="{{ path('oauth_download')}}">{{'download_btn'|trans }} </a></li>
</ul>
{% else %}
<a href="{{ path('oauth_authenticate')}}">Log in via Lokalise</a>
{% endif %}
And that should be it! Going to <http://localhost:8000/oauth
> should allow you to upload and download translation files easily!
Updated about 2 months ago