This course is still being released! Check back later for more chapters.

Get Notified About this Course!

We will send you messages regarding this course only
and nothing else, we promise.
You can unsubscribe anytime by emailing us at:
privacy@symfonycasts.com
Login to bookmark this video
Buy Access to Course
27.

Translations Import Command

|

Share this awesome video!

|

Keep on Learning!

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Before we tackle the import command, I noticed a bug in our ObjectTranslator. Open that up and take a look at the translate() method. We're using a WeakMap, keyed by the object. The problem here is if you translate an object with the French locale, then later, translate the same object with the Spanish locale, you'll get the French version back, because that's what's in the WeakMap.

A solution would be to include the locale in the cache key, but WeakMap doesn't support complex keys. We could use a nested structure: an array of WeakMap's, each keyed by locale. But to keep things simple for now, let's just remove the WeakMap entirely. I think our cache system is robust enough that we won't run into performance issues. If we do, we can always reintroduce it later.

So, remove the $translatedObjects property and all references to it below:

// ... lines 1 - 10
final class ObjectTranslator
{
// ... lines 13 - 31
public function translate(object $object, ?string $locale = null, array $options = []): object
{
// ... lines 34 - 39
return new TranslatedObject($object, $this->translationsFor(
// ... lines 41 - 43
));
}
// ... lines 46 - 67
}

The Import Command

Alright, with that out of the way, let's turn our attention to the import command. In the tutorial/ folder, copy ObjectTranslationImportCommand.php to our bundle's Command/ directory. If you don't see this file, you can copy it from the script below:

// ... lines 1 - 2
namespace SymfonyCasts\ObjectTranslationBundle\Command;
// ... lines 4 - 12
/**
* @internal
*/
#[AsCommand(
name: 'object-translation:import',
description: 'Imports object translations from a CSV.',
)]
final class ObjectTranslationImportCommand extends Command
{
public function __construct(
private TranslatableMappingManager $mappingManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to import from.')
->addArgument('locale', InputArgument::REQUIRED, 'The locale to import translations for.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$file = $input->getArgument('file');
$locale = $input->getArgument('locale');
$io = new SymfonyStyle($input, $output);
$io->title('Importing Object Translations');
$fp = fopen($file, 'r');
$io->progressStart();
fgetcsv($fp);
while (($row = fgetcsv($fp)) !== false) {
[$type, $id, $field, $value] = $row;
$this->mappingManager->upsert($type, $id, $locale, $field, $value);
$io->progressAdvance();
}
fclose($fp);
$io->progressFinish();
$io->success(sprintf('Imported "%s"', $file));
return self::SUCCESS;
}
}

Let's walk though it. It's pretty similar to the export command. In configure(), the first argument is the CSV file to import, and the second argument is the locale we're importing for.

Down in execute(), we're grabbing the file and the locale from the $input, creating the $io object, and then opening the passed file as readable. This time, we're creating a progress bar manually instead of using progressIterate(). We start the progress bar, then use fgetcsv to move past the first row of the CSV file (the headers) which we don't want to import.

Next, we loop over all the rows of the CSV file, expanding them into variables, then pass them to this not yet created upsert() method on our mapping manager. Still in this loop, we advance the progress bar and finally, close the file, finish the progress bar, and output a success message.

Cool! Let's wire it up. In our bundle's services.php file, copy the definition for the export command and paste it below. Fix the indentation, rename the id to import_command and change the class to ObjectTranslationImportCommand:

// ... lines 1 - 11
return static function (ContainerConfigurator $container): void {
$container->services()
// ... lines 14 - 44
->set('.symfonycasts.object_translator.import_command', ObjectTranslationImportCommand::class)
->args([
service('.symfonycasts.object_translator.mapping_manager'),
])
->tag('console.command')
// ... lines 50 - 52
;
};

Implementing the upsert() Method

Now for that upsert() method. Back in our command, find the upsert() call and add the method to TranslatableMappingManager. Set all the parameter types to string and the return type to void:

// ... lines 1 - 13
final class TranslatableMappingManager
{
// ... lines 16 - 105
public function upsert(string $type, string $id, string $locale, string $field, string $value): void
{
// ... lines 108 - 128
}
}

If you're not familiar with the term "upsert", it's a combination of "update" and "insert". It means to update an existing record if it exists, or insert a new one if it doesn't. This is exactly what we want to do when importing translations.

First, grab the "Object Manager" for the object translation class using $om = $this->doctrine->getManagerForClass($this->translationClass):

// ... lines 1 - 13
final class TranslatableMappingManager
{
// ... lines 16 - 105
public function upsert(string $type, string $id, string $locale, string $field, string $value): void
{
$om = $this->doctrine->getManagerForClass($this->translationClass);
// ... lines 109 - 128
}
}

Now try and find an existing translation: $translation = $om->getRepository($this->translationClass)->findOneBy(). For the criteria: 'objectType' => $type, 'objectId' => $id, 'locale' => $locale, and 'field' => $field.

// ... lines 1 - 13
final class TranslatableMappingManager
{
// ... lines 16 - 105
public function upsert(string $type, string $id, string $locale, string $field, string $value): void
{
// ... lines 108 - 109
$translation = $om->getRepository($this->translationClass)->findOneBy([
'objectType' => $type,
'objectId' => $id,
'locale' => $locale,
'field' => $field,
]);
// ... lines 116 - 128
}
}

These 4 properties uniquely identify a translation:

If we get a translation from the database, this is an update, if not, it's an insert.

Check if this translation doesn't exist with if (!$translation). In this case, we need to create a new one. So, instantiate a translation object: $translation = new ($this->translationClass)():

// ... lines 1 - 13
final class TranslatableMappingManager
{
// ... lines 16 - 105
public function upsert(string $type, string $id, string $locale, string $field, string $value): void
{
// ... lines 108 - 116
if (!$translation) {
$translation = new ($this->translationClass)();
// ... lines 119 - 122
}
// ... lines 124 - 128
}
}

Yep, you can totally instantiate a class with a variable like this!

Quickly pop into our Model/Translation class... cool, all the properties are public. So, back in our upsert() method, $translation->objectType = $type, $translation->objectId = $id, $translation->locale = $locale, and $translation->field = $field:

// ... lines 1 - 13
final class TranslatableMappingManager
{
// ... lines 16 - 105
public function upsert(string $type, string $id, string $locale, string $field, string $value): void
{
// ... lines 108 - 116
if (!$translation) {
// ... line 118
$translation->objectType = $type;
$translation->objectId = $id;
$translation->locale = $locale;
$translation->field = $field;
}
// ... lines 124 - 128
}
}

Below, set the value with $translation->value = $value:

// ... lines 1 - 13
final class TranslatableMappingManager
{
// ... lines 16 - 105
public function upsert(string $type, string $id, string $locale, string $field, string $value): void
{
// ... lines 108 - 124
$translation->value = $value;
// ... lines 126 - 128
}
}

Finally, save the translation with $om->persist($translation) and $om->flush():

// ... lines 1 - 13
final class TranslatableMappingManager
{
// ... lines 16 - 105
public function upsert(string $type, string $id, string $locale, string $field, string $value): void
{
// ... lines 108 - 126
$om->persist($translation);
$om->flush();
}
}

The persist call is required for new translations, but it's safe to call it for existing ones too.

Back in the command, the undefined method error is gone.

Testing the Import Command

Testing time! If you recall from the last chapter, we exported our object translations to this var/export.csv file. I took this file and translated the value column to Spanish and French using GitHub Copilot. In the tutorial/ directory, you'll find import.es.csv and import.fr.csv with the translated values. If you don't see them, they are linked in the script below.

Over in the terminal, import the Spanish translations by running:

symfony console object-translation:import tutorial/import.es.csv es

Cool, 15 translations imported. Now for the French:

symfony console object-translation:import tutorial/import.fr.csv fr

Because we've made changes to the database, we need to update our translation cache. Luckily, we have our warmup command:

symfony console object-translation:warmup

Over in the browser, we're on the French homepage and see our French fixture data here. Refresh... and... Sweet! All the articles are translated into French. Switch to Spanish and... Boom! Spanish articles! Click an article to see the full translated content.

Ok, the initial bundle code for a 1.0 release is done! Next, let's start working on the meta stuff we need to release this bundle to the world.